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

@@ -117,12 +117,13 @@ func main() {
authService := auth.NewService(accountRepo, wsServer, cfg.Security.SecretKey) authService := auth.NewService(accountRepo, wsServer, cfg.Security.SecretKey)
// 9. Handlers // 9. Handlers
draftsHandler := handlers.NewDraftsHandler(draftsService) draftsHandler := handlers.NewDraftsHandler(draftsService, ocrService)
billingHandler := handlers.NewBillingHandler(billingService) billingHandler := handlers.NewBillingHandler(billingService)
ocrHandler := handlers.NewOCRHandler(ocrService) ocrHandler := handlers.NewOCRHandler(ocrService)
photosHandler := handlers.NewPhotosHandler(photosService) photosHandler := handlers.NewPhotosHandler(photosService)
recommendHandler := handlers.NewRecommendationsHandler(recService) recommendHandler := handlers.NewRecommendationsHandler(recService)
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
settingsHandler.SetRMSFactory(rmsFactory)
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService) invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername) authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername)
@@ -172,6 +173,7 @@ func main() {
api.GET("/drafts", draftsHandler.GetDrafts) api.GET("/drafts", draftsHandler.GetDrafts)
api.GET("/drafts/:id", draftsHandler.GetDraft) api.GET("/drafts/:id", draftsHandler.GetDraft)
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft) api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
api.POST("/drafts/upload", draftsHandler.Upload)
// Items CRUD // Items CRUD
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)
@@ -183,6 +185,9 @@ func main() {
// Settings // Settings
api.GET("/settings", settingsHandler.GetSettings) api.GET("/settings", settingsHandler.GetSettings)
api.POST("/settings", settingsHandler.UpdateSettings) api.POST("/settings", settingsHandler.UpdateSettings)
// User Servers
api.GET("/user/servers", settingsHandler.GetUserServers)
api.POST("/user/servers/active", settingsHandler.SwitchActiveServer)
// Photos Storage // Photos Storage
api.GET("/photos", photosHandler.GetPhotos) api.GET("/photos", photosHandler.GetPhotos)
api.DELETE("/photos/:id", photosHandler.DeletePhoto) api.DELETE("/photos/:id", photosHandler.DeletePhoto)

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"time" "time"
@@ -11,15 +12,20 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"rmser/internal/services/drafts" "rmser/internal/services/drafts"
"rmser/internal/services/ocr"
"rmser/pkg/logger" "rmser/pkg/logger"
) )
type DraftsHandler struct { type DraftsHandler struct {
service *drafts.Service service *drafts.Service
ocrService *ocr.Service
} }
func NewDraftsHandler(service *drafts.Service) *DraftsHandler { func NewDraftsHandler(service *drafts.Service, ocrService *ocr.Service) *DraftsHandler {
return &DraftsHandler{service: service} return &DraftsHandler{
service: service,
ocrService: ocrService,
}
} }
func (h *DraftsHandler) GetDraft(c *gin.Context) { func (h *DraftsHandler) GetDraft(c *gin.Context) {
@@ -354,3 +360,54 @@ func (h *DraftsHandler) ReorderItems(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// Upload обрабатывает загрузку файла и прогоняет через OCR
func (h *DraftsHandler) Upload(c *gin.Context) {
// Лимит размера тела запроса (20MB)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 20<<20)
// Получаем файл из формы
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
// Открываем файл для чтения
file, err := fileHeader.Open()
if err != nil {
logger.Log.Error("Failed to open uploaded file", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
return
}
defer file.Close()
// Читаем байты файла
fileBytes, err := io.ReadAll(file)
if err != nil {
logger.Log.Error("Failed to read file bytes", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
// Получаем userID из контекста
userID := c.MustGet("userID").(uuid.UUID)
// Вызываем ProcessDocument
draft, err := h.ocrService.ProcessDocument(c.Request.Context(), userID, fileBytes, fileHeader.Filename)
if err != nil {
// Если черновик создан, но произошла ошибка OCR, возвращаем черновик со статусом ERROR
// Проверяем, что draft не nil (черновик был создан)
if draft != nil {
c.JSON(http.StatusOK, draft)
return
}
// Если черновик не был создан, возвращаем ошибку
logger.Log.Error("ProcessDocument failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Успешная обработка
c.JSON(http.StatusOK, draft)
}

View File

@@ -22,6 +22,11 @@ type SettingsHandler struct {
accountRepo account.Repository accountRepo account.Repository
catalogRepo catalog.Repository catalogRepo catalog.Repository
notifier Notifier // Поле для отправки уведомлений notifier Notifier // Поле для отправки уведомлений
rmsFactory RMSFactory
}
type RMSFactory interface {
ClearCacheForUser(userID uuid.UUID)
} }
func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler { func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler {
@@ -31,6 +36,11 @@ func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository)
} }
} }
// SetRMSFactory используется для внедрения зависимости после инициализации
func (h *SettingsHandler) SetRMSFactory(f RMSFactory) {
h.rmsFactory = f
}
// SetNotifier используется для внедрения зависимости после инициализации // SetNotifier используется для внедрения зависимости после инициализации
func (h *SettingsHandler) SetNotifier(n Notifier) { func (h *SettingsHandler) SetNotifier(n Notifier) {
h.notifier = n h.notifier = n
@@ -388,3 +398,107 @@ func (h *SettingsHandler) RemoveUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "removed"}) c.JSON(http.StatusOK, gin.H{"status": "removed"})
} }
// --- Server Management ---
// ServerShortDTO - краткая информация о сервере для списка
type ServerShortDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"` // OWNER, ADMIN, OPERATOR
IsActive bool `json:"is_active"`
}
// GetUserServers возвращает список всех серверов пользователя с ролями и флагом активности
func (h *SettingsHandler) GetUserServers(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
servers, err := h.accountRepo.GetAllAvailableServers(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
activeServer, err := h.accountRepo.GetActiveServer(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var activeServerID *uuid.UUID
if activeServer != nil {
activeServerID = &activeServer.ID
}
response := make([]ServerShortDTO, 0, len(servers))
for _, server := range servers {
role, err := h.accountRepo.GetUserRole(userID, server.ID)
if err != nil {
role = account.RoleOperator
}
response = append(response, ServerShortDTO{
ID: server.ID.String(),
Name: server.Name,
Role: string(role),
IsActive: activeServerID != nil && server.ID == *activeServerID,
})
}
c.JSON(http.StatusOK, response)
}
// SwitchActiveServerRequest - запрос на переключение активного сервера
type SwitchActiveServerRequest struct {
ServerID string `json:"server_id" binding:"required"`
}
// SwitchActiveServer переключает активный сервер пользователя
func (h *SettingsHandler) SwitchActiveServer(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
var req SwitchActiveServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
serverID, err := uuid.Parse(req.ServerID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server_id format"})
return
}
// Проверяем, что сервер доступен пользователю
servers, err := h.accountRepo.GetAllAvailableServers(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var serverExists bool
for _, s := range servers {
if s.ID == serverID {
serverExists = true
break
}
}
if !serverExists {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found or not accessible"})
return
}
// Переключаем активный сервер
if err := h.accountRepo.SetActiveServer(userID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Сбрасываем кэш RMS клиента
if h.rmsFactory != nil {
h.rmsFactory.ClearCacheForUser(userID)
}
c.JSON(http.StatusOK, gin.H{"status": "active_server_changed"})
}

View File

@@ -1,279 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pack_project_dump.py
Упаковывает код проекта в один текстовый файл, удобный для анализа:
- дерево файлов
- затем содержимое каждого файла в блоках с маркерами
- фильтрация мусорных директорий (node_modules, dist, build и т.п.)
- лимит размера на файл, чтобы не раздувать дамп
- попытка декодирования utf-8 с заменой ошибок
Пример:
python pack_project_dump.py --root . --out project_dump.txt
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
DEFAULT_EXCLUDE_DIRS = {
"node_modules",
"dist",
"build",
".next",
".cache",
".turbo",
".vercel",
"coverage",
".git",
".idea",
".vscode",
}
DEFAULT_EXCLUDE_FILES = {
"package-lock.json", # можно оставить, но часто огромный
"yarn.lock", # можно оставить, но часто огромный
"pnpm-lock.yaml", # можно оставить, но часто огромный
}
DEFAULT_TEXT_EXTS = {
".js", ".jsx", ".ts", ".tsx",
".json", ".md", ".css", ".scss", ".sass", ".less",
".html", ".yml", ".yaml",
".env", ".env.example",
".gitignore", ".editorconfig",
".txt",
".mjs", ".cjs", "Dockerfile",
}
@dataclass(frozen=True)
class FileEntry:
rel_path: str
size: int
sha256: str
def sha256_bytes(data: bytes) -> str:
h = hashlib.sha256()
h.update(data)
return h.hexdigest()
def is_probably_text(path: Path, extra_exts: Optional[set[str]] = None) -> bool:
ext = path.suffix.lower()
if extra_exts and ext in extra_exts:
return True
if ext in DEFAULT_TEXT_EXTS:
return True
# Файлы без расширения, но “текстовые” по имени
if path.name in {".eslintrc", ".prettierrc"}:
return True
return False
def should_exclude_path(
rel_parts: Tuple[str, ...],
exclude_dirs: set[str],
exclude_file_globs: List[str],
exclude_files: set[str],
) -> bool:
# исключаем директории по любому сегменту пути
if any(part in exclude_dirs for part in rel_parts[:-1]):
return True
name = rel_parts[-1] if rel_parts else ""
if name in exclude_files:
return True
rel_str = "/".join(rel_parts)
for pat in exclude_file_globs:
if fnmatch.fnmatch(rel_str, pat) or fnmatch.fnmatch(name, pat):
return True
return False
def iter_project_files(
root: Path,
exclude_dirs: set[str],
exclude_files: set[str],
exclude_file_globs: List[str],
) -> Iterable[Path]:
for dirpath, dirnames, filenames in os.walk(root):
# фильтруем dirnames на месте, чтобы os.walk не заходил внутрь
dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
for fname in filenames:
p = Path(dirpath) / fname
rel = p.relative_to(root)
rel_parts = tuple(rel.parts)
if should_exclude_path(rel_parts, exclude_dirs, exclude_file_globs, exclude_files):
continue
yield p
def build_tree_listing(paths: List[Path], root: Path) -> str:
rels = sorted(str(p.relative_to(root)).replace(os.sep, "/") for p in paths)
lines = ["Дерево файлов:"]
for r in rels:
lines.append(f"- {r}")
return "\n".join(lines) + "\n"
def read_file_bytes(path: Path, max_file_bytes: int) -> Tuple[bytes, bool]:
data = path.read_bytes()
if len(data) > max_file_bytes:
return data[:max_file_bytes], True
return data, False
def decode_text(data: bytes) -> str:
# Пытаемся utf-8; если ошибки — заменяем, чтобы не падать
return data.decode("utf-8", errors="replace")
def pack_dump(
root: Path,
out_path: Path,
include_globs: List[str],
exclude_dirs: set[str],
exclude_files: set[str],
exclude_file_globs: List[str],
max_file_kb: int,
only_text: bool,
) -> None:
max_file_bytes = max_file_kb * 1024
all_files = list(iter_project_files(root, exclude_dirs, exclude_files, exclude_file_globs))
# apply include globs if provided
if include_globs:
def match_any(rel: str) -> bool:
return any(fnmatch.fnmatch(rel, g) for g in include_globs)
filtered = []
for p in all_files:
rel = str(p.relative_to(root)).replace(os.sep, "/")
if match_any(rel):
filtered.append(p)
all_files = filtered
entries: List[FileEntry] = []
blocks: List[str] = []
# дерево проекта
blocks.append(f"Снимок проекта: {root.resolve()}")
blocks.append(f"Дата (UTC): {datetime.now(timezone.utc).isoformat()}")
blocks.append("")
blocks.append(build_tree_listing(all_files, root))
for p in sorted(all_files, key=lambda x: str(x)):
rel = str(p.relative_to(root)).replace(os.sep, "/")
if only_text and not is_probably_text(p):
continue
try:
raw, truncated = read_file_bytes(p, max_file_bytes)
except Exception as e:
blocks.append("<<<FILE_BEGIN>>>")
blocks.append(f"path: {rel}")
blocks.append("error: не удалось прочитать файл")
blocks.append(f"exception: {type(e).__name__}: {e}")
blocks.append("<<<FILE_END>>>")
blocks.append("")
continue
sha = sha256_bytes(raw)
size_on_disk = p.stat().st_size
entries.append(FileEntry(rel_path=rel, size=size_on_disk, sha256=sha))
text = decode_text(raw)
blocks.append("<<<FILE_BEGIN>>>")
blocks.append(f"path: {rel}")
blocks.append(f"size_bytes: {size_on_disk}")
blocks.append(f"sha256_first_{max_file_kb}kb: {sha}")
if truncated:
blocks.append(f"truncated: true (первые {max_file_kb} KB)")
else:
blocks.append("truncated: false")
blocks.append("<<<CONTENT>>>")
blocks.append(text)
blocks.append("<<<FILE_END>>>")
blocks.append("")
# краткий индекс
blocks.insert(
0,
"Индекс файлов (путь | размер | sha256 первых N KB):\n"
+ "\n".join(f"- {e.rel_path} | {e.size} | {e.sha256}" for e in entries)
+ "\n"
)
out_path.write_text("\n".join(blocks), encoding="utf-8")
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=".", help="Корень проекта")
ap.add_argument("--out", default="react_ts_frontend.txt", help="Файл-выход (один)")
ap.add_argument(
"--include",
action="append",
default=[],
help="Глоб-паттерн для включения (можно несколько), например: 'src/**' или '**/*.tsx'",
)
ap.add_argument(
"--exclude-file",
action="append",
default=[],
help="Глоб-паттерн для исключения файлов, например: '**/*.min.js'",
)
ap.add_argument(
"--max-file-kb",
type=int,
default=512,
help="Максимальный объём на один файл (KB). Остальное отрежется.",
)
ap.add_argument(
"--only-text",
action="store_true",
help="Включать только вероятно текстовые файлы по расширению/имени",
)
return ap.parse_args()
def main() -> None:
args = parse_args()
root = Path(args.root).resolve()
out_path = Path(args.out).resolve()
pack_dump(
root=root,
out_path=out_path,
include_globs=args.include,
exclude_dirs=set(DEFAULT_EXCLUDE_DIRS),
exclude_files=set(DEFAULT_EXCLUDE_FILES),
exclude_file_globs=args.exclude_file,
max_file_kb=args.max_file_kb,
only_text=args.only_text,
)
print(f"Готово: {out_path}")
if __name__ == "__main__":
main()

View File

@@ -14,12 +14,14 @@
"antd": "^6.1.0", "antd": "^6.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@@ -2636,6 +2638,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2873,6 +2884,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2899,6 +2923,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2964,6 +2997,18 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3489,6 +3534,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3674,6 +3728,17 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4252,9 +4317,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.10.1", "version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -4274,12 +4339,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.10.1", "version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.10.1" "react-router": "7.13.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -4412,6 +4477,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-convert": { "node_modules/string-convert": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -4702,6 +4779,24 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4712,6 +4807,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -16,12 +16,14 @@
"antd": "^6.1.0", "antd": "^6.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"immer": "^11.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"xlsx": "^0.18.5",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -132,12 +132,14 @@ const AppContent = () => {
<Route index element={<Navigate to="/invoices" replace />} /> <Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} /> <Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<DraftsList />} /> <Route path="invoices" element={<DraftsList />} />
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
{/* Роуты для детальных страниц накладных (без AppLayout - на весь экран) */}
<Route path="/invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="/invoice/view/:id" element={<InvoiceViewPage />} />
{/* Десктопные роуты */} {/* Десктопные роуты */}
<Route path="/web" element={<DesktopAuthScreen />} /> <Route path="/web" element={<DesktopAuthScreen />} />
<Route path="/web" element={<DesktopLayout />}> <Route path="/web" element={<DesktopLayout />}>

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect } from "react";
import { Modal, Button, message } from "antd";
import {
ZoomInOutlined,
ZoomOutOutlined,
UndoOutlined,
} from "@ant-design/icons";
import * as XLSX from "xlsx";
interface ExcelPreviewModalProps {
visible: boolean;
onCancel: () => void;
fileUrl: string;
}
/**
* Компонент для предпросмотра Excel файлов
* Позволяет просматривать содержимое Excel файлов с возможностью масштабирования
*/
const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
visible,
onCancel,
fileUrl,
}) => {
// Данные таблицы из Excel файла
const [data, setData] = useState<
(string | number | boolean | null | undefined)[][]
>([]);
// Масштаб отображения таблицы
const [scale, setScale] = useState<number>(1);
/**
* Загрузка и парсинг Excel файла при изменении видимости или URL файла
*/
useEffect(() => {
const loadExcelFile = async () => {
if (!visible || !fileUrl) {
return;
}
try {
// Загрузка файла как arrayBuffer
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Ошибка загрузки файла: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
// Чтение Excel файла
const workbook = XLSX.read(arrayBuffer, { type: "array" });
// Получение первого листа
const firstSheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[firstSheetName];
// Преобразование листа в JSON-массив массивов
const jsonData = XLSX.utils.sheet_to_json(sheet, {
header: 1,
}) as (string | number | boolean | null | undefined)[][];
setData(jsonData);
// Сброс масштаба при загрузке нового файла
setScale(1);
} catch (error) {
console.error("Ошибка при загрузке Excel файла:", error);
message.error("Не удалось загрузить Excel файл");
setData([]);
}
};
loadExcelFile();
}, [visible, fileUrl]);
/**
* Увеличение масштаба
*/
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.1, 3));
};
/**
* Уменьшение масштаба
*/
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.1, 0.5));
};
/**
* Сброс масштаба до исходного значения
*/
const handleReset = () => {
setScale(1);
};
return (
<Modal
visible={visible}
onCancel={onCancel}
width="90%"
footer={null}
title="Предпросмотр Excel"
style={{ top: 20, zIndex: 10000 }}
>
{/* Панель инструментов для управления масштабом */}
<div style={{ marginBottom: 16, display: "flex", gap: 8 }}>
<Button
icon={<ZoomInOutlined />}
onClick={handleZoomIn}
disabled={scale >= 3}
>
Увеличить (+)
</Button>
<Button
icon={<ZoomOutOutlined />}
onClick={handleZoomOut}
disabled={scale <= 0.5}
>
Уменьшить (-)
</Button>
<Button
icon={<UndoOutlined />}
onClick={handleReset}
disabled={scale === 1}
>
Сброс
</Button>
<span style={{ marginLeft: "auto", lineHeight: "32px" }}>
Масштаб: {Math.round(scale * 100)}%
</span>
</div>
{/* Контейнер с прокруткой для таблицы */}
<div
style={{
height: 600,
overflow: "auto",
border: "1px solid #d9d9d9",
borderRadius: 4,
}}
>
{/* Таблица с данными Excel */}
<table
style={{
borderCollapse: "collapse",
transform: `scale(${scale})`,
transformOrigin: "top left",
width: "100%",
}}
>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
style={{
border: "1px solid #ccc",
padding: "8px",
minWidth: 100,
whiteSpace: "nowrap",
}}
>
{cell !== undefined && cell !== null ? String(cell) : ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Modal>
);
};
export default ExcelPreviewModal;

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

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect, useRef } from "react"; import React, { useMemo, useState } from "react";
import { Draggable } from "@hello-pangea/dnd"; import { Draggable } from "@hello-pangea/dnd";
import { import {
Card, Card,
@@ -23,7 +23,6 @@ import { CatalogSelect } from "../ocr/CatalogSelect";
import { CreateContainerModal } from "./CreateContainerModal"; import { CreateContainerModal } from "./CreateContainerModal";
import type { import type {
DraftItem, DraftItem,
UpdateDraftItemRequest,
ProductSearchResult, ProductSearchResult,
ProductContainer, ProductContainer,
Recommendation, Recommendation,
@@ -31,22 +30,20 @@ import type {
const { Text } = Typography; const { Text } = Typography;
interface Props { interface DraftItemRowProps {
item: DraftItem; item: DraftItem;
index: number; index: number;
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void; onLocalUpdate: (id: string, changes: Partial<DraftItem>) => void;
onDelete: (itemId: string) => void; onDelete: (itemId: string) => void;
isUpdating: boolean; isUpdating: boolean;
recommendations?: Recommendation[]; recommendations?: Recommendation[];
isReordering: boolean; isReordering: boolean;
} }
type FieldType = "quantity" | "price" | "sum"; export const DraftItemRow: React.FC<DraftItemRowProps> = ({
export const DraftItemRow: React.FC<Props> = ({
item, item,
index, index,
onUpdate, onLocalUpdate,
onDelete, onDelete,
isUpdating, isUpdating,
recommendations = [], recommendations = [],
@@ -54,151 +51,14 @@ export const DraftItemRow: React.FC<Props> = ({
}) => { }) => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
// --- Локальное состояние значений (строки для удобства ввода) ---
const [localQty, setLocalQty] = useState<number | null>(item.quantity);
const [localPrice, setLocalPrice] = useState<number | null>(item.price);
const [localSum, setLocalSum] = useState<number | null>(item.sum);
// --- История редактирования (Stack) ---
// Храним 2 последних отредактированных поля.
// Инициализируем из пропсов или дефолтно ['quantity', 'price'], чтобы пересчитывалась сумма.
const editStack = useRef<FieldType[]>([
(item.last_edited_field_1 as FieldType) || "quantity",
(item.last_edited_field_2 as FieldType) || "price",
]);
// Храним ссылку на предыдущую версию item, чтобы сравнивать изменения
// --- Синхронизация с сервером ---
useEffect(() => {
// Если мы ждем ответа от сервера, не сбиваем локальный ввод
if (isUpdating) return;
// Обновляем локальные стейты только когда меняются конкретные поля в item
setLocalQty(item.quantity);
setLocalPrice(item.price);
setLocalSum(item.sum);
// Обновляем стек редактирования
if (item.last_edited_field_1 && item.last_edited_field_2) {
editStack.current = [
item.last_edited_field_1 as FieldType,
item.last_edited_field_2 as FieldType,
];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// Зависим ТОЛЬКО от примитивов. Если объект item изменится, но цифры те же - эффект не сработает.
item.quantity,
item.price,
item.sum,
item.last_edited_field_1,
item.last_edited_field_2,
isUpdating,
]);
// --- Логика пересчета (Треугольник) ---
const recalculateLocally = (changedField: FieldType, newVal: number) => {
// 1. Обновляем стек истории
// Удаляем поле, если оно уже было в стеке, и добавляем в начало (LIFO для важности)
const currentStack = editStack.current.filter((f) => f !== changedField);
currentStack.unshift(changedField);
// Оставляем только 2 последних
if (currentStack.length > 2) currentStack.pop();
editStack.current = currentStack;
// 2. Определяем, какое поле нужно пересчитать (то, которого НЕТ в стеке)
const allFields: FieldType[] = ["quantity", "price", "sum"];
const fieldToRecalc = allFields.find((f) => !currentStack.includes(f));
// 3. Выполняем расчет
let q = changedField === "quantity" ? newVal : localQty || 0;
let p = changedField === "price" ? newVal : localPrice || 0;
let s = changedField === "sum" ? newVal : localSum || 0;
switch (fieldToRecalc) {
case "sum":
s = q * p;
setLocalSum(s);
break;
case "quantity":
if (p !== 0) {
q = s / p;
setLocalQty(q);
} else {
setLocalQty(0);
}
break;
case "price":
if (q !== 0) {
p = s / q;
setLocalPrice(p);
} else {
setLocalPrice(0);
}
break;
}
};
// --- Обработчики ввода ---
const handleValueChange = (field: FieldType, val: number | null) => {
// Обновляем само поле
if (field === "quantity") setLocalQty(val);
if (field === "price") setLocalPrice(val);
if (field === "sum") setLocalSum(val);
if (val !== null) {
recalculateLocally(field, val);
}
};
const handleBlur = (field: FieldType) => {
// Отправляем на сервер только измененное поле + маркер edited_field.
// Сервер сам проведет пересчет и вернет точные данные.
// Важно: отправляем текущее локальное значение.
let val: number | null = null;
if (field === "quantity") val = localQty;
if (field === "price") val = localPrice;
if (field === "sum") val = localSum;
if (val === null) return;
// Сравниваем с текущим item, чтобы не спамить запросами, если число не поменялось
const serverVal = item[field];
// Используем эпсилон для сравнения float
if (Math.abs(val - serverVal) > 0.0001) {
onUpdate(item.id, {
[field]: val,
edited_field: field,
});
}
};
// --- Product & Container Logic (как было) ---
const [searchedProduct, setSearchedProduct] =
useState<ProductSearchResult | null>(null);
const [addedContainers, setAddedContainers] = useState<
Record<string, ProductContainer[]>
>({});
const activeProduct = useMemo(() => { const activeProduct = useMemo(() => {
if (searchedProduct && searchedProduct.id === item.product_id)
return searchedProduct;
return item.product as unknown as ProductSearchResult | undefined; return item.product as unknown as ProductSearchResult | undefined;
}, [searchedProduct, item.product, item.product_id]); }, [item.product]);
const containers = useMemo(() => { const containers = useMemo(() => {
if (!activeProduct) return []; if (!activeProduct) return [];
const baseContainers = activeProduct.containers || []; return activeProduct.containers || [];
const manuallyAdded = addedContainers[activeProduct.id] || []; }, [activeProduct]);
const combined = [...baseContainers];
manuallyAdded.forEach((c) => {
if (!combined.find((existing) => existing.id === c.id)) combined.push(c);
});
return combined;
}, [activeProduct, addedContainers]);
const baseUom = const baseUom =
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед."; activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
@@ -255,34 +115,41 @@ export const DraftItemRow: React.FC<Props> = ({
// --- Handlers --- // --- Handlers ---
const handleProductChange = ( const handleProductChange = (
prodId: string, prodId: string | null,
productObj?: ProductSearchResult productObj?: ProductSearchResult
) => { ) => {
if (productObj) setSearchedProduct(productObj); onLocalUpdate(item.id, {
onUpdate(item.id, {
product_id: prodId, product_id: prodId,
container_id: null, // Сбрасываем фасовку product: prodId ? productObj : null,
// При смене товара логично оставить Qty и Sum, пересчитав Price? container_id: null,
// Или оставить Qty и Price? Обычно цена меняется.
// Пока не трогаем числа, пусть остаются как были.
}); });
}; };
const handleContainerChange = (val: string) => { const handleContainerChange = (val: string) => {
// "" пустая строка приходит при выборе "Базовая" (мы так настроим value)
const newVal = val === "BASE_UNIT" ? "" : val; const newVal = val === "BASE_UNIT" ? "" : val;
onUpdate(item.id, { container_id: newVal }); onLocalUpdate(item.id, {
container_id: newVal,
});
}; };
const handleContainerCreated = (newContainer: ProductContainer) => { const handleContainerCreated = (newContainer: ProductContainer) => {
setIsModalOpen(false); setIsModalOpen(false);
if (activeProduct) { onLocalUpdate(item.id, { container_id: newContainer.id });
setAddedContainers((prev) => ({ };
...prev,
[activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer], const handleValueChange = (
})); field: "quantity" | "price" | "sum",
val: number | null
) => {
if (val !== null) {
onLocalUpdate(item.id, {
[field]: Number(val),
});
} }
onUpdate(item.id, { container_id: newContainer.id }); };
const handleBlur = () => {
// Изменения уже отправлены через onLocalUpdate в handleValueChange
}; };
const cardBorderColor = !item.product_id const cardBorderColor = !item.product_id
@@ -293,7 +160,11 @@ export const DraftItemRow: React.FC<Props> = ({
return ( return (
<> <>
<Draggable draggableId={item.id} index={index} isDragDisabled={!isReordering}> <Draggable
draggableId={item.id}
index={index}
isDragDisabled={!isReordering}
>
{(provided, snapshot) => { {(provided, snapshot) => {
const style = { const style = {
marginBottom: "8px", marginBottom: "8px",
@@ -420,7 +291,7 @@ export const DraftItemRow: React.FC<Props> = ({
</Flex> </Flex>
<CatalogSelect <CatalogSelect
value={item.product_id || undefined} value={item.product_id || null}
onChange={handleProductChange} onChange={handleProductChange}
initialProduct={activeProduct} initialProduct={activeProduct}
/> />
@@ -476,10 +347,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false} controls={false}
placeholder="Кол" placeholder="Кол"
min={0} min={0}
value={localQty} value={item.quantity}
onChange={(val) => handleValueChange("quantity", val)} onChange={(val) => handleValueChange("quantity", val)}
onBlur={() => handleBlur("quantity")} onBlur={() => handleBlur()}
precision={3} precision={3}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/> />
<Text type="secondary">x</Text> <Text type="secondary">x</Text>
<InputNumber <InputNumber
@@ -487,10 +361,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false} controls={false}
placeholder="Цена" placeholder="Цена"
min={0} min={0}
value={localPrice} value={item.price}
onChange={(val) => handleValueChange("price", val)} onChange={(val) => handleValueChange("price", val)}
onBlur={() => handleBlur("price")} onBlur={() => handleBlur()}
precision={2} precision={2}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/> />
</div> </div>
@@ -503,10 +380,13 @@ export const DraftItemRow: React.FC<Props> = ({
controls={false} controls={false}
placeholder="Сумма" placeholder="Сумма"
min={0} min={0}
value={localSum} value={item.sum}
onChange={(val) => handleValueChange("sum", val)} onChange={(val) => handleValueChange("sum", val)}
onBlur={() => handleBlur("sum")} onBlur={() => handleBlur()}
precision={2} precision={2}
parser={(value) =>
value?.replace(",", ".") as unknown as number
}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,217 @@
import React, { useState } from "react";
import { Modal, Spin, Button, Typography, Alert, message } from "antd";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import { api } from "../../services/api";
import { DraftItemRow } from "./DraftItemRow";
import type { UpdateDraftItemRequest, DraftItem } from "../../services/types";
const { Text } = Typography;
interface DraftVerificationModalProps {
draftId: string;
visible: boolean;
onClose: () => void;
}
/**
* Модальное окно для быстрой проверки черновика после загрузки файла
* Позволяет просмотреть и отредактировать позиции перед переходом к полному редактированию
*/
export const DraftVerificationModal: React.FC<DraftVerificationModalProps> = ({
draftId,
visible,
onClose,
}) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// Получаем данные черновика
const draftQuery = useQuery({
queryKey: ["draft", draftId],
queryFn: () => api.getDraft(draftId),
enabled: visible && !!draftId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "PROCESSING" ? 3000 : false;
},
});
// Получаем рекомендации
const recommendationsQuery = useQuery({
queryKey: ["recommendations"],
queryFn: api.getRecommendations,
enabled: visible,
});
// Мутация для обновления строки
const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(draftId, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems((prev) => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
},
onError: () => {
message.error("Не удалось сохранить строку");
},
onSettled: (_data, _err, vars) => {
setUpdatingItems((prev) => {
const next = new Set(prev);
next.delete(vars.itemId);
return next;
});
},
});
const draft = draftQuery.data;
const recommendations = recommendationsQuery.data || [];
const handleItemUpdate = (itemId: string, changes: Partial<DraftItem>) => {
// Преобразуем Partial<DraftItem> в UpdateDraftItemRequest
const payload: UpdateDraftItemRequest = {};
if (changes.product_id !== undefined) {
// product_id может быть null, но UpdateDraftItemRequest ожидает только UUID
// Если null, не включаем поле в payload
if (changes.product_id !== null) {
payload.product_id = changes.product_id;
}
}
if (changes.container_id !== undefined) {
payload.container_id = changes.container_id;
}
if (changes.quantity !== undefined) {
payload.quantity = changes.quantity;
}
if (changes.price !== undefined) {
payload.price = changes.price;
}
if (changes.sum !== undefined) {
payload.sum = changes.sum;
}
updateItemMutation.mutate({ itemId, payload });
};
const handleDeleteItem = () => {
// В модальном окне не реализуем удаление для упрощения
// Пользователь может перейти к полному редактированию
message.info("Для удаления позиции перейдите к полному редактированию");
};
const handleGoToFullEdit = () => {
onClose();
navigate(`/web/invoice/draft/${draftId}`);
};
const invalidItemsCount =
draft?.items.filter((i) => !i.product_id).length || 0;
return (
<Modal
title="Проверка черновика"
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
Закрыть
</Button>,
<Button
key="edit"
type="primary"
onClick={handleGoToFullEdit}
disabled={draftQuery.isLoading}
>
Перейти к полному редактированию
</Button>,
]}
destroyOnClose
>
{draftQuery.isLoading ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin size="large" />
<div style={{ marginTop: "16px" }}>
<Text type="secondary">Обработка файла...</Text>
</div>
</div>
) : draftQuery.isError ? (
<Alert
message="Ошибка загрузки черновика"
description="Не удалось загрузить данные черновика. Попробуйте закрыть модальное окно и загрузить файл заново."
type="error"
showIcon
/>
) : draft ? (
<div>
{/* Информация о черновике */}
<div style={{ marginBottom: "16px" }}>
<Text strong>Номер: </Text>
<Text>{draft.document_number || "Не указан"}</Text>
<br />
<Text strong>Статус: </Text>
<Text>{draft.status}</Text>
{draft.photo_url && (
<>
<br />
<Text strong>Фото чека: </Text>
<Text type="secondary">Загружено</Text>
</>
)}
</div>
{/* Предупреждение о нераспознанных товарах */}
{invalidItemsCount > 0 && (
<Alert
message={`${invalidItemsCount} позиций не распознано`}
description="Пожалуйста, сопоставьте товары с каталогом перед отправкой"
type="warning"
showIcon
style={{ marginBottom: "16px" }}
/>
)}
{/* Список позиций */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
{draft.items.length === 0 ? (
<div style={{ textAlign: "center", padding: "20px" }}>
<Text type="secondary">Нет позиций</Text>
</div>
) : (
<DragDropContext onDragEnd={() => {}}>
<Droppable droppableId="modal-verification-list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{draft.items.map((item, index) => (
<DraftItemRow
key={item.id}
item={item}
index={index}
onLocalUpdate={handleItemUpdate}
onDelete={handleDeleteItem}
isUpdating={updatingItems.has(item.id)}
recommendations={recommendations}
isReordering={false}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
</div>
) : null}
</Modal>
);
};

View File

@@ -0,0 +1,263 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
import {
FileImageOutlined,
FileExcelOutlined,
HistoryOutlined,
RestOutlined,
} from "@ant-design/icons";
import { api, getStaticUrl } from "../../services/api";
import type { DraftStatus } from "../../services/types";
import ExcelPreviewModal from "../common/ExcelPreviewModal";
const { Title, Text } = Typography;
interface InvoiceViewerProps {
invoiceId: string;
onBack?: () => void;
}
export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
invoiceId,
onBack,
}) => {
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Состояние для просмотра Excel файла
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
// Запрос данных накладной
const {
data: invoice,
isLoading,
isError,
} = useQuery({
queryKey: ["invoice", invoiceId],
queryFn: () => api.getInvoice(invoiceId),
enabled: !!invoiceId,
});
// Определение типа файла по расширению
const isExcelFile = invoice?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
const getStatusTag = (status: DraftStatus) => {
switch (status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return (
<Tag icon={<HistoryOutlined />} color="success">
Синхронизировано
</Tag>
);
case "ERROR":
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (isError || !invoice) {
return <Alert type="error" message="Ошибка загрузки накладной" />;
}
const columns = [
{
title: "Товар",
dataIndex: "name",
key: "name",
},
{
title: "Кол-во",
dataIndex: "quantity",
key: "quantity",
align: "right" as const,
},
{
title: "Сумма",
dataIndex: "total",
key: "total",
align: "right" as const,
render: (total: number) =>
total.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
];
const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total,
0
);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
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={<RestOutlined />}
onClick={onBack}
size="small"
style={{ flexShrink: 0 }}
/>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 0,
}}
>
<Title
level={5}
style={{ margin: 0, fontSize: 16, whiteSpace: "nowrap" }}
>
{invoice.number}
</Title>
<Text
type="secondary"
style={{ fontSize: 11, whiteSpace: "nowrap" }}
>
{invoice.date} {invoice.supplier.name}
</Text>
</div>
</div>
{/* Правая часть: Статус + Кнопка фото */}
<div
style={{
display: "flex",
gap: 6,
alignItems: "center",
flexShrink: 0,
}}
>
{getStatusTag(invoice.status)}
{/* Кнопка просмотра чека (только если есть URL) */}
{invoice.photo_url && (
<Button
icon={isExcelFile ? <FileExcelOutlined /> : <FileImageOutlined />}
onClick={() =>
isExcelFile
? setExcelPreviewVisible(true)
: setPreviewVisible(true)
}
size="small"
type="text"
/>
)}
</div>
</div>
{/* Основная часть с прокруткой */}
<div style={{ flex: 1, overflowY: "auto", padding: "8px 12px" }}>
{/* Таблица товаров */}
<div
style={{
background: "#fff",
padding: 12,
borderRadius: 8,
marginBottom: 16,
}}
>
<Title level={5} style={{ marginBottom: 12, fontSize: 14 }}>
Товары ({(invoice.items || []).length} поз.)
</Title>
<Table
columns={columns}
dataSource={invoice.items || []}
pagination={false}
rowKey="name"
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2}>
<Text strong>Итого:</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
})}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div>
</div>
{/* Скрытый компонент для просмотра изображения */}
{invoice.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(invoice.photo_url)} />
</Image.PreviewGroup>
</div>
)}
{/* Модальное окно для просмотра Excel файлов */}
<ExcelPreviewModal
visible={excelPreviewVisible}
onCancel={() => setExcelPreviewVisible(false)}
fileUrl={invoice.photo_url ? getStaticUrl(invoice.photo_url) : ""}
/>
</div>
);
};

View File

@@ -144,10 +144,10 @@ export const AddMatchForm: React.FC<Props> = ({
// --- Хендлеры --- // --- Хендлеры ---
const handleProductChange = ( const handleProductChange = (
val: string, val: string | null,
productObj?: ProductSearchResult productObj?: ProductSearchResult
) => { ) => {
setSelectedProduct(val); setSelectedProduct(val || undefined);
if (productObj) { if (productObj) {
setSelectedProductData(productObj); setSelectedProductData(productObj);
} }
@@ -282,7 +282,7 @@ export const AddMatchForm: React.FC<Props> = ({
Товар в iiko: Товар в iiko:
</div> </div>
<CatalogSelect <CatalogSelect
value={selectedProduct} value={selectedProduct || null}
onChange={handleProductChange} onChange={handleProductChange}
disabled={isLoading} disabled={isLoading}
initialProduct={activeProduct} // Передаем полный объект для правильного отображения! initialProduct={activeProduct} // Передаем полный объект для правильного отображения!

View File

@@ -4,8 +4,8 @@ import { api } from "../../services/api";
import type { CatalogItem, ProductSearchResult } from "../../services/types"; import type { CatalogItem, ProductSearchResult } from "../../services/types";
interface Props { interface Props {
value?: string; value: string | null;
onChange?: (value: string, productObj?: ProductSearchResult) => void; onChange: (value: string | null, productObj?: ProductSearchResult) => void;
disabled?: boolean; disabled?: boolean;
initialProduct?: CatalogItem | ProductSearchResult; initialProduct?: CatalogItem | ProductSearchResult;
} }
@@ -85,12 +85,12 @@ export const CatalogSelect: React.FC<Props> = ({
}; };
const handleChange = ( const handleChange = (
val: string, val: string | undefined,
option: SelectOption | SelectOption[] | undefined option: SelectOption | SelectOption[] | undefined
) => { ) => {
if (onChange) { if (onChange) {
const opt = Array.isArray(option) ? option[0] : option; const opt = Array.isArray(option) ? option[0] : option;
onChange(val, opt?.data); onChange(val ?? null, opt?.data);
} }
}; };
@@ -108,7 +108,7 @@ export const CatalogSelect: React.FC<Props> = ({
) : null ) : null
} }
options={options} options={options}
value={value} value={value || undefined}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
style={{ width: "100%" }} style={{ width: "100%" }}
@@ -118,6 +118,7 @@ export const CatalogSelect: React.FC<Props> = ({
onClear={() => { onClear={() => {
setOptions([]); setOptions([]);
setNotFound(false); setNotFound(false);
onChange(null);
}} }}
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым // При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
onFocus={() => { onFocus={() => {

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React, { useEffect } from "react";
import { Layout, Space, Avatar, Dropdown, Button } from 'antd'; import { Layout, Space, Avatar, Dropdown, Select } from "antd";
import { UserOutlined, LogoutOutlined } from '@ant-design/icons'; import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from "../../stores/authStore";
import { useServerStore } from "../../stores/serverStore";
const { Header } = Layout; const { Header } = Layout;
@@ -11,16 +12,27 @@ const { Header } = Layout;
*/ */
export const DesktopHeader: React.FC = () => { export const DesktopHeader: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { servers, activeServer, isLoading, fetchServers, setActiveServer } =
useServerStore();
// Загружаем список серверов при маунте компонента
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
window.location.href = '/web'; window.location.href = "/web";
};
const handleServerChange = (serverId: string) => {
setActiveServer(serverId);
}; };
const userMenuItems = [ const userMenuItems = [
{ {
key: 'logout', key: "logout",
label: 'Выйти', label: "Выйти",
icon: <LogoutOutlined />, icon: <LogoutOutlined />,
onClick: handleLogout, onClick: handleLogout,
}, },
@@ -29,55 +41,65 @@ export const DesktopHeader: React.FC = () => {
return ( return (
<Header <Header
style={{ style={{
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
backgroundColor: '#ffffff', backgroundColor: "#ffffff",
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)', boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
padding: '0 24px', padding: "0 24px",
height: '64px', height: "64px",
position: 'fixed', position: "fixed",
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
zIndex: 1000, zIndex: 1000,
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}> <div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
{/* Логотип */} {/* Логотип */}
<div <div
style={{ style={{
fontSize: '20px', fontSize: "20px",
fontWeight: 'bold', fontWeight: "bold",
color: '#1890ff', color: "#1890ff",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: '8px', gap: "8px",
}} }}
> >
<span>RMSer</span> <span>RMSer</span>
</div> </div>
{/* Заглушка выбора сервера */} {/* Выбор сервера */}
<Button <Select
type="default" placeholder="Выберите сервер"
ghost value={activeServer?.id || undefined}
style={{ onChange={handleServerChange}
color: '#8c8c8c', loading={isLoading}
borderColor: '#d9d9d9', disabled={isLoading}
cursor: 'default', style={{ minWidth: "200px" }}
}} options={servers.map((server) => ({
> label: server.name,
Сервер не выбран value: server.id,
</Button> }))}
/>
</div> </div>
{/* Аватар пользователя */} {/* Аватар пользователя */}
<Space> <Space>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}> <div
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Avatar size="default" icon={<UserOutlined />} /> <Avatar size="default" icon={<UserOutlined />} />
<span style={{ color: '#262626' }}>{user?.username || 'Пользователь'}</span> <span style={{ color: "#262626" }}>
{user?.username || "Пользователь"}
</span>
</div> </div>
</Dropdown> </Dropdown>
</Space> </Space>

View File

@@ -1,651 +1,18 @@
import React, { useEffect, useMemo, useState } from "react"; import React from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { DraftEditor } from "../components/invoices/DraftEditor";
import {
Spin,
Alert,
Button,
Form,
Select,
DatePicker,
Input,
Typography,
message,
Row,
Col,
Affix,
Modal,
Tag,
Image,
} from "antd";
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
RestOutlined,
PlusOutlined,
FileImageOutlined,
SwapOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { api, getStaticUrl } from "../services/api";
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;
const { confirm } = Modal;
export const InvoiceDraftPage: React.FC = () => { export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id: draftId } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set()); if (!draftId) {
const [itemsOrder, setItemsOrder] = useState<Record<string, number>>({}); return null;
const [isDragging, setIsDragging] = useState(false);
const [isReordering, setIsReordering] = useState(false);
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = 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", id],
queryFn: () => api.getDraft(id!),
enabled: !!id,
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 updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload),
onMutate: async ({ itemId }) => {
setUpdatingItems((prev) => new Set(prev).add(itemId));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error("Не удалось сохранить строку");
},
onSettled: (_data, _err, vars) => {
setUpdatingItems((prev) => {
const next = new Set(prev);
next.delete(vars.itemId);
return next;
});
},
});
const addItemMutation = useMutation({
mutationFn: () => api.addDraftItem(id!),
onSuccess: () => {
message.success("Строка добавлена");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error("Ошибка создания строки");
},
});
const deleteItemMutation = useMutation({
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
onSuccess: () => {
message.success("Строка удалена");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
},
onError: () => {
message.error("Ошибка удаления строки");
},
});
const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => {
message.success(`Накладная ${data.document_number} создана!`);
navigate("/invoices");
queryClient.invalidateQueries({ queryKey: ["drafts"] });
},
onError: () => {
message.error("Ошибка при создании накладной");
},
});
const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === "CANCELED") {
message.info("Черновик удален окончательно");
navigate("/invoices");
} else {
message.warning("Черновик отменен");
queryClient.invalidateQueries({ queryKey: ["draft", id] });
}
},
onError: () => {
message.error("Ошибка при удалении");
},
});
const reorderItemsMutation = useMutation({
mutationFn: ({
draftId,
payload,
}: {
draftId: string;
payload: ReorderDraftItemsRequest;
}) => api.reorderDraftItems(draftId, payload),
onError: (error) => {
message.error("Не удалось изменить порядок элементов");
console.error("Reorder error:", error);
},
});
// --- ЭФФЕКТЫ ---
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 (
draft?.items.reduce(
(acc, item) => acc + Number(item.quantity) * Number(item.price),
0
) || 0
);
}, [draft?.items]);
const invalidItemsCount = useMemo(() => {
return draft?.items.filter((i) => !i.product_id).length || 0;
}, [draft?.items]);
const handleItemUpdate = (
itemId: string,
changes: UpdateDraftItemRequest
) => {
updateItemMutation.mutate({ itemId, payload: changes });
};
const handleCommit = async () => {
try {
const values = await form.validateFields();
if (invalidItemsCount > 0) {
message.warning(
`Осталось ${invalidItemsCount} нераспознанных товаров!`
);
return;
}
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 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;
// Сохраняем предыдущее состояние для отката
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 ||
(draft?.status === "PROCESSING" &&
(!draft?.items || draft.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 ( return (
<div style={{ paddingBottom: 60 }}> <div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{/* Header */} <DraftEditor draftId={draftId} onBack={() => navigate("/invoices")} />
<div
style={{
marginBottom: 12,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/invoices")}
size="small"
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "wrap",
}}
>
<span
style={{ fontSize: 18, fontWeight: "bold", whiteSpace: "nowrap" }}
>
{draft.document_number ? `${draft.document_number}` : "Черновик"}
</span>
{draft.status === "PROCESSING" && <Spin size="small" />}
{isCanceled && (
<Tag color="red" style={{ margin: 0 }}>
ОТМЕНЕН
</Tag>
)}
</div>
</div>
{/* Правая часть хедера: Кнопка чека, Кнопка перетаскивания и Кнопка удаления */}
<div style={{ display: "flex", gap: 8 }}>
{/* Кнопка просмотра чека (только если есть URL) */}
{draft.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
{/* Кнопка переключения режима перетаскивания */}
<Button
type={isReordering ? "primary" : "default"}
icon={<SwapOutlined rotate={90} />}
onClick={() => setIsReordering(!isReordering)}
size="small"
>
{isReordering ? "Ок" : ""}
</Button>
<Button
danger={isCanceled}
type={isCanceled ? "primary" : "default"}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? "Удалить" : "Отмена"}
</Button>
</div>
</div>
{/* Form: Склады и Поставщики */}
<div
style={{
background: "#fff",
padding: 12,
borderRadius: 8,
marginBottom: 12,
opacity: isCanceled ? 0.6 : 1,
}}
>
<Form
form={form}
layout="vertical"
initialValues={{ date_incoming: dayjs() }}
>
<Row gutter={10}>
<Col span={12}>
<Form.Item
label="Дата"
name="date_incoming"
rules={[{ required: true }]}
style={{ marginBottom: 8 }}
>
<DatePicker
style={{ width: "100%" }}
format="DD.MM.YYYY"
size="middle"
/>
</Form.Item>
</Col>
<Col span={12}>
{/* Входящий номер */}
<Form.Item
label="Входящий номер"
name="incoming_document_number"
style={{ marginBottom: 8 }}
>
<Input placeholder="№ Документа" size="middle" />
</Form.Item>
</Col>
</Row>
<Row gutter={10}>
<Col span={24}>
<Form.Item
label="Склад"
name="store_id"
rules={[{ required: true, message: "Выберите склад" }]}
style={{ marginBottom: 8 }}
>
<Select
placeholder="Куда?"
loading={dictQuery.isLoading}
options={stores.map((s) => ({ label: s.name, value: s.id }))}
size="middle"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
label="Поставщик"
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="middle"
showSearch
filterOption={(input, option) =>
(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item
label="Комментарий"
name="comment"
style={{ marginBottom: 0 }}
>
<TextArea
rows={1}
placeholder="Комментарий..."
style={{ fontSize: 13 }}
/>
</Form.Item>
</Form>
</div>
{/* Items Header */}
<div
style={{
marginBottom: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 4px",
}}
>
<Text strong>Позиции ({draft.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",
}}
>
{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 || []}
isReordering={isReordering}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{/* Кнопка добавления позиции */}
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginTop: 12, marginBottom: 80, height: 48 }}
onClick={() => addItemMutation.mutate()}
loading={addItemMutation.isPending}
disabled={isCanceled}
>
Добавить товар
</Button>
{/* Footer Actions */}
<Affix offsetBottom={60}>
<div
style={{
background: "#fff",
padding: "8px 16px",
borderTop: "1px solid #eee",
boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderRadius: "8px 8px 0 0",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 11, color: "#888", lineHeight: 1 }}>
Итого:
</span>
<span
style={{
fontSize: 18,
fontWeight: "bold",
color: "#1890ff",
lineHeight: 1.2,
}}
>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleCommit}
loading={commitMutation.isPending}
disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: "0 24px" }}
>
{isCanceled ? "Восстановить" : "Отправить"}
</Button>
</div>
</Affix>
{/* Скрытый компонент для просмотра изображения */}
{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>
)}
</div> </div>
); );
}; };

View File

@@ -1,211 +1,19 @@
// src/pages/InvoiceViewPage.tsx import React from "react";
import React, { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
import {
ArrowLeftOutlined,
FileImageOutlined,
HistoryOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api, getStaticUrl } from "../services/api"; import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
import type { DraftStatus } from "../services/types";
const { Title, Text } = Typography;
export const InvoiceViewPage: React.FC = () => { export const InvoiceViewPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id: invoiceId } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Запрос данных накладной
const {
data: invoice,
isLoading,
isError,
} = useQuery({
queryKey: ["invoice", id],
queryFn: () => api.getInvoice(id!),
enabled: !!id,
});
const getStatusTag = (status: DraftStatus) => {
switch (status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return ( return (
<Tag icon={<HistoryOutlined />} color="success"> <div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
Синхронизировано {invoiceId && (
</Tag> <InvoiceViewer
); invoiceId={invoiceId}
case "ERROR": onBack={() => navigate("/invoices")}
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (isError || !invoice) {
return <Alert type="error" message="Ошибка загрузки накладной" />;
}
const columns = [
{
title: "Товар",
dataIndex: "name",
key: "name",
},
{
title: "Кол-во",
dataIndex: "quantity",
key: "quantity",
align: "right" as const,
},
{
title: "Сумма",
dataIndex: "total",
key: "total",
align: "right" as const,
render: (total: number) =>
total.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
];
const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total,
0
);
return (
<div style={{ paddingBottom: 20 }}>
{/* Header */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/invoices")}
size="small"
/> />
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<Title level={4} style={{ margin: 0 }}>
{invoice.number}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{invoice.date} {invoice.supplier.name}
</Text>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{getStatusTag(invoice.status)}
{/* Кнопка просмотра чека (только если есть URL) */}
{invoice.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
</div>
</div>
{/* Таблица товаров */}
<div
style={{
background: "#fff",
padding: 16,
borderRadius: 8,
marginBottom: 16,
}}
>
<Title level={5} style={{ marginBottom: 16 }}>
Товары ({(invoice.items || []).length} поз.)
</Title>
<Table
columns={columns}
dataSource={invoice.items || []}
pagination={false}
rowKey="name"
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2}>
<Text strong>Итого:</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<Text strong>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
})}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div>
{/* Скрытый компонент для просмотра изображения */}
{invoice.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(invoice.photo_url)} />
</Image.PreviewGroup>
</div>
)} )}
</div> </div>
); );

View File

@@ -1,6 +1,11 @@
import React from "react"; import React, { useState } from "react";
import { Typography, Card, List, Empty } from "antd"; import { Typography, Card, List, Empty, Tag, Spin } from "antd";
import { DragDropZone } from "../../../components/DragDropZone"; import { UploadOutlined } from "@ant-design/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DraftVerificationModal } from "../../../components/invoices/DraftVerificationModal";
import { api } from "../../../services/api";
import { useServerStore } from "../../../stores/serverStore";
import type { UnifiedInvoice } from "../../../services/types";
const { Title } = Typography; const { Title } = Typography;
@@ -9,45 +14,129 @@ const { Title } = Typography;
* Содержит зону для загрузки файлов и список черновиков * Содержит зону для загрузки файлов и список черновиков
*/ */
export const InvoicesDashboard: React.FC = () => { export const InvoicesDashboard: React.FC = () => {
const handleDrop = (files: File[]) => { const { activeServer } = useServerStore();
console.log("Файлы загружены:", files); const queryClient = useQueryClient();
// TODO: Добавить логику обработки файлов
// Состояние для Drag-n-Drop
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [verificationDraftId, setVerificationDraftId] = useState<string | null>(
null
);
// Загружаем список черновиков через useQuery
const { data: drafts, isLoading } = useQuery({
queryKey: ["drafts", activeServer?.id],
queryFn: () => api.getDrafts(),
enabled: !!activeServer, // Запрос выполняется только если выбран сервер
});
// Обработчики Drag-n-Drop
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Проверяем, что перетаскивается файл
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
}; };
// Заглушка списка черновиков const handleDragLeave = (e: React.DragEvent) => {
const mockDrafts = [ e.preventDefault();
{ // Проверяем, что мы действительно покидаем элемент, а не просто переходим к дочернему
id: "1", const rect = e.currentTarget.getBoundingClientRect();
title: "Черновик #1", const x = e.clientX;
date: "2024-01-15", const y = e.clientY;
status: "В работе",
},
{
id: "2",
title: "Черновик #2",
date: "2024-01-14",
status: "Черновик",
},
];
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Разрешаем drop
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Берем только первый файл
const file = files[0];
try {
setIsUploading(true);
const draft = await api.uploadFile(file);
// Обновляем список черновиков
queryClient.invalidateQueries({ queryKey: ["drafts", activeServer?.id] });
// Открываем модальное окно проверки
setVerificationDraftId(draft.id);
} catch (error) {
console.error("Ошибка загрузки файла:", error);
} finally {
setIsUploading(false);
}
};
const handleCloseVerification = () => {
setVerificationDraftId(null);
};
// Если сервер не выбран, показываем сообщение
if (!activeServer) {
return ( return (
<div> <div>
<Title level={2}>Черновики</Title> <Title level={2}>Черновики</Title>
<Card>
{/* Зона для загрузки файлов */} <Empty
<Card style={{ marginBottom: "24px" }}> description="Выберите сервер сверху"
<DragDropZone onDrop={handleDrop} /> image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card> </Card>
</div>
);
}
{/* Список черновиков (заглушка) */} return (
<Card title="Последние черновики"> <div
style={{ position: "relative" }}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Title level={2}>Черновики</Title>
{/* Список черновиков */}
<Card title="Последние черновики" loading={isLoading}>
<List <List
dataSource={mockDrafts} dataSource={drafts || []}
renderItem={(draft) => ( renderItem={(draft: UnifiedInvoice) => (
<List.Item> <List.Item>
<List.Item.Meta <List.Item.Meta
title={draft.title} title={
description={`${draft.date}${draft.status}`} <div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>{draft.document_number}</span>
{draft.type === "DRAFT" && <Tag color="blue">Черновик</Tag>}
{draft.type === "SYNCED" && (
<Tag color="green">Синхронизировано</Tag>
)}
</div>
}
description={`${draft.date_incoming}${
draft.store_name || "Склад не указан"
}${draft.items_count} позиций`}
/> />
</List.Item> </List.Item>
)} )}
@@ -61,6 +150,65 @@ export const InvoicesDashboard: React.FC = () => {
}} }}
/> />
</Card> </Card>
{/* Overlay для Drag-n-Drop */}
{isDragOver && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(255, 255, 255, 0.9)",
zIndex: 100,
border: "3px dashed #1890ff",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<UploadOutlined
style={{ fontSize: "64px", color: "#1890ff", marginBottom: "16px" }}
/>
<Typography.Text style={{ fontSize: "18px", color: "#1890ff" }}>
Отпустите файл для загрузки
</Typography.Text>
</div>
)}
{/* Лоадер загрузки файла */}
{isUploading && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)",
zIndex: 1000,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<Spin size="large" />
<Typography.Text style={{ color: "#fff", marginTop: "16px" }}>
Загрузка файла...
</Typography.Text>
</div>
)}
{/* Модальное окно проверки черновика */}
{verificationDraftId && (
<DraftVerificationModal
draftId={verificationDraftId}
visible={!!verificationDraftId}
onClose={handleCloseVerification}
/>
)}
</div> </div>
); );
}; };

View File

@@ -28,7 +28,8 @@ import type {
ServerUser, ServerUser,
UserRole, UserRole,
InvoiceDetails, InvoiceDetails,
GetPhotosResponse GetPhotosResponse,
ServerShort
} from './types'; } from './types';
// Интерфейс для ответа метода инициализации десктопной авторизации // Интерфейс для ответа метода инициализации десктопной авторизации
@@ -212,6 +213,11 @@ export const api = {
return data; return data;
}, },
updateDraft: async (id: string, payload: Partial<CommitDraftRequest>): Promise<DraftInvoice> => {
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${id}`, payload);
return data;
},
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => { updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload); const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data; return data;
@@ -314,5 +320,31 @@ export const api = {
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop'); const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
return data; return data;
}, },
// --- Управление серверами ---
getUserServers: async (): Promise<ServerShort[]> => {
const { data } = await apiClient.get<ServerShort[]>('/user/servers');
return data;
},
switchServer: async (serverId: string): Promise<{ status: string }> => {
const { data } = await apiClient.post<{ status: string }>(`/user/servers/active`, { server_id: serverId });
return data;
},
// --- Загрузка файлов для создания черновика ---
uploadFile: async (file: File): Promise<DraftInvoice> => {
const formData = new FormData();
formData.append('file', file);
const { data } = await apiClient.post<DraftInvoice>('/drafts/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return data;
},
}; };

View File

@@ -5,6 +5,14 @@ export type UUID = string;
// Добавляем типы ролей // Добавляем типы ролей
export type UserRole = 'OWNER' | 'ADMIN' | 'OPERATOR'; export type UserRole = 'OWNER' | 'ADMIN' | 'OPERATOR';
// Краткая информация о сервере
export interface ServerShort {
id: string;
name: string;
role: UserRole;
is_active: boolean;
}
// Интерфейс пользователя сервера // Интерфейс пользователя сервера
export interface ServerUser { export interface ServerUser {
user_id: string; user_id: string;
@@ -185,7 +193,7 @@ export interface DraftItem {
// Мета-данные // Мета-данные
is_matched: boolean; is_matched: boolean;
product?: CatalogItem; product?: CatalogItem | null;
container?: ProductContainer; container?: ProductContainer;
// Поля для синхронизации состояния (опционально, если бэкенд их отдает) // Поля для синхронизации состояния (опционально, если бэкенд их отдает)
@@ -221,7 +229,7 @@ export interface DraftInvoice {
// DTO для обновления строки // DTO для обновления строки
export interface UpdateDraftItemRequest { export interface UpdateDraftItemRequest {
product_id?: UUID; product_id?: UUID | null;
container_id?: UUID | null; container_id?: UUID | null;
quantity?: number; quantity?: number;
price?: number; price?: number;

View File

@@ -0,0 +1,185 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { DraftItem } from '../services/types';
import { recalculateItem } from '../utils/calculations';
/**
* Интерфейс хранилища активного черновика
*
* Управляет локальным состоянием элементов черновика накладной.
* Отслеживает изменения через флаг isDirty.
*/
interface ActiveDraftStore {
/** Массив элементов черновика */
items: DraftItem[];
/** Флаг, указывающий на наличие несохраненных изменений */
isDirty: boolean;
/**
* Заменяет весь массив элементов черновика
*
* @param items - Новый массив элементов
*/
setItems: (items: DraftItem[]) => void;
/**
* Обновляет элемент черновика по идентификатору
*
* При изменении числовых полей (quantity, price, sum) автоматически
* пересчитывает зависимые значения с помощью recalculateItem.
*
* @param id - Идентификатор элемента для обновления
* @param changes - Частичные изменения элемента
*
* @remarks
* Если меняется product_id, quantity и price НЕ сбрасываются в 0.
* При изменении любого поля устанавливает isDirty = true.
*/
updateItem: (id: string, changes: Partial<DraftItem>) => void;
/**
* Удаляет элемент черновика по идентификатору
*
* @param id - Идентификатор элемента для удаления
*/
deleteItem: (id: string) => void;
/**
* Добавляет новый элемент в черновик
*
* @param item - Новый элемент для добавления
*/
addItem: (item: DraftItem) => void;
/**
* Перемещает элемент с одной позиции на другую
*
* @param startIndex - Исходная позиция элемента
* @param endIndex - Новая позиция элемента
*/
reorderItems: (startIndex: number, endIndex: number) => void;
/**
* Сбрасывает флаг isDirty в false
* Используется после успешного сохранения изменений на сервер
*/
resetDirty: () => void;
}
/**
* Zustand стор для управления состоянием активного черновика
*
* Использует immer middleware для иммутабельных обновлений состояния.
* Все изменения элементов автоматически устанавливают флаг isDirty = true,
* кроме метода setItems, который сбрасывает флаг в false.
*/
export const useActiveDraftStore = create<ActiveDraftStore>()(
immer((set) => ({
// Начальное состояние
items: [],
isDirty: false,
/**
* Заменяет весь массив элементов черновика
* Сбрасывает флаг isDirty в false
*/
setItems: (items: DraftItem[]) =>
set((state) => {
state.items = items;
state.isDirty = false;
}),
/**
* Обновляет элемент черновика по идентификатору
*
* Логика:
* 1. Находит элемент по id
* 2. Если меняются числовые поля (quantity, price, sum), использует recalculateItem
* 3. Устанавливает isDirty = true
* 4. При изменении product_id НЕ сбрасывает quantity и price
*/
updateItem: (id: string, changes: Partial<DraftItem>) =>
set((state) => {
const itemIndex = state.items.findIndex((item: DraftItem) => item.id === id);
if (itemIndex === -1) {
return;
}
const item = state.items[itemIndex];
// Проверяем, изменились ли числовые поля
const numericFields: Array<keyof DraftItem> = ['quantity', 'price', 'sum'];
const changedNumericField = numericFields.find(
(field) =>
changes[field] !== undefined &&
changes[field] !== item[field]
);
if (changedNumericField) {
// Используем recalculateItem для пересчета зависимых полей
const newValue = changes[changedNumericField] as number;
const recalculated = recalculateItem(
item,
changedNumericField as 'quantity' | 'price' | 'sum',
newValue
);
// Применяем пересчитанные значения и остальные изменения
state.items[itemIndex] = {
...recalculated,
...changes,
};
} else {
// Просто применяем изменения без пересчета
state.items[itemIndex] = {
...item,
...changes,
};
}
state.isDirty = true;
}),
/**
* Удаляет элемент черновика по идентификатору
* Устанавливает isDirty = true
*/
deleteItem: (id: string) =>
set((state) => {
state.items = state.items.filter((item: DraftItem) => item.id !== id);
state.isDirty = true;
}),
/**
* Добавляет новый элемент в черновик
* Устанавливает isDirty = true
*/
addItem: (item: DraftItem) =>
set((state) => {
state.items.push(item);
state.isDirty = true;
}),
/**
* Перемещает элемент с позиции startIndex на endIndex
* Устанавливает isDirty = true
*/
reorderItems: (startIndex: number, endIndex: number) =>
set((state) => {
const [removed] = state.items.splice(startIndex, 1);
state.items.splice(endIndex, 0, removed);
state.isDirty = true;
}),
/**
* Сбрасывает флаг isDirty в false
* Используется после успешного сохранения изменений на сервер
*/
resetDirty: () =>
set((state) => {
state.isDirty = false;
}),
}))
);

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import type { ServerShort } from '../services/types';
import { api } from '../services/api';
interface ServerState {
servers: ServerShort[];
activeServer: ServerShort | null;
isLoading: boolean;
error: string | null;
fetchServers: () => Promise<void>;
setActiveServer: (id: string) => Promise<void>;
}
/**
* Хранилище состояния серверов
* Управляет списком доступных серверов и текущим активным сервером
*/
export const useServerStore = create<ServerState>((set, get) => ({
servers: [],
activeServer: null,
isLoading: false,
error: null,
fetchServers: async () => {
set({ isLoading: true, error: null });
try {
const servers = await api.getUserServers();
const activeServer = servers.find(s => s.is_active) || null;
set({ servers, activeServer, isLoading: false });
} catch (error) {
console.error('Ошибка при загрузке списка серверов:', error);
set({
error: error instanceof Error ? error.message : 'Не удалось загрузить список серверов',
isLoading: false
});
}
},
setActiveServer: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.switchServer(id);
// После успешного переключения перезагружаем список серверов
// чтобы обновить флаг is_active
await get().fetchServers();
} catch (error) {
console.error('Ошибка при переключении сервера:', error);
set({
error: error instanceof Error ? error.message : 'Не удалось переключить сервер',
isLoading: false
});
}
},
}));

View File

@@ -0,0 +1,66 @@
import type { DraftItem } from '../services/types';
/**
* Пересчитывает значения полей элемента черновика на основе измененного поля.
*
* @param item - Исходный элемент черновика
* @param changedField - Измененное поле ('quantity' | 'price' | 'sum')
* @param newValue - Новое значение измененного поля
* @returns Новый объект DraftItem с пересчитанными значениями
*
* @example
* // При изменении количества
* const updated = recalculateItem(item, 'quantity', 5);
*
* @example
* // При изменении суммы
* const updated = recalculateItem(item, 'sum', 100);
*/
export function recalculateItem(
item: DraftItem,
changedField: 'quantity' | 'price' | 'sum',
newValue: number
): DraftItem {
switch (changedField) {
case 'quantity': {
// При изменении количества пересчитываем сумму: sum = qty * price
return {
...item,
quantity: newValue,
sum: newValue * item.price,
};
}
case 'price': {
// При изменении цены пересчитываем сумму: sum = qty * price
return {
...item,
price: newValue,
sum: item.quantity * newValue,
};
}
case 'sum': {
// При изменении суммы пересчитываем цену: price = sum / qty
// Обрабатываем случай деления на ноль
if (item.quantity === 0) {
return {
...item,
sum: newValue,
price: 0,
};
}
return {
...item,
sum: newValue,
price: newValue / item.quantity,
};
}
default: {
// Для неизвестных полей возвращаем исходный объект
return { ...item };
}
}
}