mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2901-zustend для стора. сохранение черновиков построчно
редактор xml пока не работает, но есть ui переработал
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
130
rmser-view/package-lock.json
generated
130
rmser-view/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 />}>
|
||||||
|
|||||||
177
rmser-view/src/components/common/ExcelPreviewModal.tsx
Normal file
177
rmser-view/src/components/common/ExcelPreviewModal.tsx
Normal 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;
|
||||||
762
rmser-view/src/components/invoices/DraftEditor.tsx
Normal file
762
rmser-view/src/components/invoices/DraftEditor.tsx
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
import React, { useEffect, useMemo, useState, useRef } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Spin,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
Input,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Modal,
|
||||||
|
Tag,
|
||||||
|
Image,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExclamationCircleFilled,
|
||||||
|
StopOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { api, getStaticUrl } from "../../services/api";
|
||||||
|
import { DraftItemRow } from "./DraftItemRow";
|
||||||
|
import ExcelPreviewModal from "../common/ExcelPreviewModal";
|
||||||
|
import { useActiveDraftStore } from "../../stores/activeDraftStore";
|
||||||
|
import type {
|
||||||
|
DraftItem,
|
||||||
|
CommitDraftRequest,
|
||||||
|
ReorderDraftItemsRequest,
|
||||||
|
} from "../../services/types";
|
||||||
|
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { confirm } = Modal;
|
||||||
|
|
||||||
|
interface DraftEditorProps {
|
||||||
|
draftId: string;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftEditor: React.FC<DraftEditorProps> = ({
|
||||||
|
draftId,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// Zustand стор для локального управления элементами черновика
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
isDirty,
|
||||||
|
setItems,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
addItem,
|
||||||
|
reorderItems,
|
||||||
|
resetDirty,
|
||||||
|
} = useActiveDraftStore();
|
||||||
|
|
||||||
|
// Отслеживаем текущий draftId для инициализации стора
|
||||||
|
const currentDraftIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Состояние для просмотра фото чека
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
|
||||||
|
// Состояние для просмотра Excel файла
|
||||||
|
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
|
||||||
|
|
||||||
|
// --- ЗАПРОСЫ ---
|
||||||
|
|
||||||
|
const dictQuery = useQuery({
|
||||||
|
queryKey: ["dictionaries"],
|
||||||
|
queryFn: api.getDictionaries,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recommendationsQuery = useQuery({
|
||||||
|
queryKey: ["recommendations"],
|
||||||
|
queryFn: api.getRecommendations,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftQuery = useQuery({
|
||||||
|
queryKey: ["draft", draftId],
|
||||||
|
queryFn: () => api.getDraft(draftId),
|
||||||
|
enabled: !!draftId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (isDragging) return false;
|
||||||
|
const status = query.state.data?.status;
|
||||||
|
return status === "PROCESSING" ? 3000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const draft = draftQuery.data;
|
||||||
|
const stores = dictQuery.data?.stores || [];
|
||||||
|
const suppliers = dictQuery.data?.suppliers || [];
|
||||||
|
|
||||||
|
// Определение типа файла по расширению
|
||||||
|
const isExcelFile = draft?.photo_url?.toLowerCase().match(/\.(xls|xlsx)$/);
|
||||||
|
|
||||||
|
// --- МУТАЦИИ ---
|
||||||
|
|
||||||
|
const addItemMutation = useMutation({
|
||||||
|
mutationFn: () => api.addDraftItem(draftId),
|
||||||
|
onSuccess: (newItem) => {
|
||||||
|
message.success("Строка добавлена");
|
||||||
|
// Добавляем новый элемент в стор
|
||||||
|
addItem(newItem);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка создания строки");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitMutation = useMutation({
|
||||||
|
mutationFn: (payload: CommitDraftRequest) =>
|
||||||
|
api.commitDraft(draftId, payload),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
message.success(`Накладная ${data.document_number} создана!`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка при создании накладной");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDraftMutation = useMutation({
|
||||||
|
mutationFn: () => api.deleteDraft(draftId),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (draft?.status === "CANCELED") {
|
||||||
|
message.info("Черновик удален окончательно");
|
||||||
|
} else {
|
||||||
|
message.warning("Черновик отменен");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка при удалении");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderItemsMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
draftId: id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
draftId: string;
|
||||||
|
payload: ReorderDraftItemsRequest;
|
||||||
|
}) => api.reorderDraftItems(id, payload),
|
||||||
|
onError: (error) => {
|
||||||
|
message.error("Не удалось изменить порядок элементов");
|
||||||
|
console.error("Reorder error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- ЭФФЕКТЫ ---
|
||||||
|
|
||||||
|
// Инициализация стора при загрузке черновика
|
||||||
|
useEffect(() => {
|
||||||
|
if (draft && draft.items) {
|
||||||
|
// Инициализируем стор только если изменился draftId или стор пуст
|
||||||
|
if (currentDraftIdRef.current !== draft.id || items.length === 0) {
|
||||||
|
setItems(draft.items);
|
||||||
|
currentDraftIdRef.current = draft.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [draft, items.length, setItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draft) {
|
||||||
|
const currentValues = form.getFieldsValue();
|
||||||
|
if (!currentValues.store_id && draft.store_id)
|
||||||
|
form.setFieldValue("store_id", draft.store_id);
|
||||||
|
if (!currentValues.supplier_id && draft.supplier_id)
|
||||||
|
form.setFieldValue("supplier_id", draft.supplier_id);
|
||||||
|
if (!currentValues.comment && draft.comment)
|
||||||
|
form.setFieldValue("comment", draft.comment);
|
||||||
|
|
||||||
|
// Инициализация входящего номера
|
||||||
|
if (
|
||||||
|
!currentValues.incoming_document_number &&
|
||||||
|
draft.incoming_document_number
|
||||||
|
)
|
||||||
|
form.setFieldValue(
|
||||||
|
"incoming_document_number",
|
||||||
|
draft.incoming_document_number
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentValues.date_incoming)
|
||||||
|
form.setFieldValue(
|
||||||
|
"date_incoming",
|
||||||
|
draft.date_incoming ? dayjs(draft.date_incoming) : dayjs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [draft, form]);
|
||||||
|
|
||||||
|
// --- ХЕЛПЕРЫ ---
|
||||||
|
const totalSum = useMemo(() => {
|
||||||
|
return (
|
||||||
|
items.reduce(
|
||||||
|
(acc, item) => acc + Number(item.quantity) * Number(item.price),
|
||||||
|
0
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const invalidItemsCount = useMemo(() => {
|
||||||
|
return items.filter((i) => !i.product_id).length || 0;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// Функция сохранения изменений на сервер
|
||||||
|
const saveChanges = async () => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// Собираем значения формы для обновления шапки черновика
|
||||||
|
const formValues = form.getFieldsValue();
|
||||||
|
|
||||||
|
// Подготавливаем payload для обновления мета-данных черновика
|
||||||
|
const draftPayload: Partial<CommitDraftRequest> = {
|
||||||
|
store_id: formValues.store_id,
|
||||||
|
supplier_id: formValues.supplier_id,
|
||||||
|
comment: formValues.comment || "",
|
||||||
|
incoming_document_number: formValues.incoming_document_number || "",
|
||||||
|
date_incoming: formValues.date_incoming
|
||||||
|
? formValues.date_incoming.format("YYYY-MM-DD")
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохраняем все измененные элементы
|
||||||
|
const savePromises = items.map((item) =>
|
||||||
|
api.updateDraftItem(draftId, item.id, {
|
||||||
|
product_id: item.product_id ?? null,
|
||||||
|
container_id: item.container_id ?? null,
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
price: Number(item.price),
|
||||||
|
sum: Number(item.sum),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Параллельно сохраняем шапку и строки
|
||||||
|
await Promise.all([
|
||||||
|
api.updateDraft(draftId, draftPayload),
|
||||||
|
...savePromises,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// После успешного сохранения обновляем данные с сервера
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["draft", draftId] });
|
||||||
|
|
||||||
|
// Сбрасываем флаг isDirty
|
||||||
|
resetDirty();
|
||||||
|
|
||||||
|
message.success("Изменения сохранены");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка сохранения:", error);
|
||||||
|
message.error("Не удалось сохранить изменения");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemUpdate = (id: string, changes: Partial<DraftItem>) => {
|
||||||
|
// Обновляем локально через стор
|
||||||
|
updateItem(id, changes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
if (invalidItemsCount > 0) {
|
||||||
|
message.warning(
|
||||||
|
`Осталось ${invalidItemsCount} нераспознанных товаров!`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала сохраняем изменения, если есть
|
||||||
|
if (isDirty) {
|
||||||
|
await saveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
commitMutation.mutate({
|
||||||
|
date_incoming: values.date_incoming.format("YYYY-MM-DD"),
|
||||||
|
store_id: values.store_id,
|
||||||
|
supplier_id: values.supplier_id,
|
||||||
|
comment: values.comment || "",
|
||||||
|
incoming_document_number: values.incoming_document_number || "",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCanceled = draft?.status === "CANCELED";
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
confirm({
|
||||||
|
title: "Сохранить изменения?",
|
||||||
|
icon: <ExclamationCircleFilled style={{ color: "#1890ff" }} />,
|
||||||
|
content:
|
||||||
|
"У вас есть несохраненные изменения. Сохранить их перед выходом?",
|
||||||
|
okText: "Сохранить и выйти",
|
||||||
|
cancelText: "Выйти без сохранения",
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await saveChanges();
|
||||||
|
onBack?.();
|
||||||
|
} catch {
|
||||||
|
// Ошибка уже обработана в saveChanges
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
onBack?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onBack?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
confirm({
|
||||||
|
title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?",
|
||||||
|
icon: <ExclamationCircleFilled style={{ color: "red" }} />,
|
||||||
|
content: isCanceled
|
||||||
|
? "Черновик пропадет из списка навсегда."
|
||||||
|
: 'Черновик получит статус "Отменен", но останется в списке.',
|
||||||
|
okText: isCanceled ? "Удалить навсегда" : "Отменить",
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: "Назад",
|
||||||
|
onOk() {
|
||||||
|
deleteDraftMutation.mutate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async (result: DropResult) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
const { source, destination } = result;
|
||||||
|
|
||||||
|
// Если нет назначения или позиция не изменилась
|
||||||
|
if (
|
||||||
|
!destination ||
|
||||||
|
(source.droppableId === destination.droppableId &&
|
||||||
|
source.index === destination.index)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft) return;
|
||||||
|
|
||||||
|
// Обновляем локальное состояние через стор
|
||||||
|
reorderItems(source.index, destination.index);
|
||||||
|
|
||||||
|
// Подготавливаем payload для API
|
||||||
|
const reorderPayload: ReorderDraftItemsRequest = {
|
||||||
|
items: items.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
order: index,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем запрос на сервер
|
||||||
|
try {
|
||||||
|
await reorderItemsMutation.mutateAsync({
|
||||||
|
draftId: draft.id,
|
||||||
|
payload: reorderPayload,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// При ошибке откатываем локальное состояние через стор
|
||||||
|
reorderItems(destination.index, source.index);
|
||||||
|
message.error("Не удалось изменить порядок элементов");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RENDER ---
|
||||||
|
const showSpinner =
|
||||||
|
draftQuery.isLoading ||
|
||||||
|
(draft?.status === "PROCESSING" && items.length === 0);
|
||||||
|
|
||||||
|
if (showSpinner) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: 50 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftQuery.isError || !draft) {
|
||||||
|
return <Alert type="error" message="Ошибка загрузки черновика" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Единый хедер */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "#fff",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Левая часть: Кнопка назад + Номер + Статус */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={handleBack}
|
||||||
|
size="small"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 16, fontWeight: "bold", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{draft.document_number ? `№${draft.document_number}` : "Черновик"}
|
||||||
|
</Text>
|
||||||
|
{draft.status === "PROCESSING" && <Spin size="small" />}
|
||||||
|
{isCanceled && (
|
||||||
|
<Tag color="red" style={{ margin: 0, fontSize: 11 }}>
|
||||||
|
ОТМЕНЕН
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая часть: Кнопки действий */}
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
{/* Кнопка сохранения (показывается только если есть несохраненные изменения) */}
|
||||||
|
{isDirty && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={saveChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||||
|
{draft.photo_url && (
|
||||||
|
<Button
|
||||||
|
icon={isExcelFile ? <FileExcelOutlined /> : <FileImageOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
isExcelFile
|
||||||
|
? setExcelPreviewVisible(true)
|
||||||
|
: setPreviewVisible(true)
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка переключения режима перетаскивания */}
|
||||||
|
<Button
|
||||||
|
type={isReordering ? "primary" : "default"}
|
||||||
|
icon={<SwapOutlined rotate={90} />}
|
||||||
|
onClick={() => setIsReordering(!isReordering)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Кнопка удаления/отмены */}
|
||||||
|
<Button
|
||||||
|
danger={isCanceled}
|
||||||
|
type={isCanceled ? "primary" : "default"}
|
||||||
|
icon={isCanceled ? <DeleteOutlined /> : <StopOutlined />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={deleteDraftMutation.isPending}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основная часть с прокруткой */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "8px 12px" }}>
|
||||||
|
{/* Form: Склады и Поставщики */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
opacity: isCanceled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ date_incoming: dayjs() }}
|
||||||
|
>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="date_incoming"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
format="DD.MM.YYYY"
|
||||||
|
placeholder="Дата..."
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="incoming_document_number"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="№ входящего..." size="small" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item
|
||||||
|
name="store_id"
|
||||||
|
rules={[{ required: true, message: "Выберите склад" }]}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Выберите склад..."
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={stores.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
}))}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item
|
||||||
|
name="supplier_id"
|
||||||
|
rules={[{ required: true, message: "Выберите поставщика" }]}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Поставщик..."
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||||
|
size="small"
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="comment" style={{ marginBottom: 0 }}>
|
||||||
|
<TextArea
|
||||||
|
rows={1}
|
||||||
|
placeholder="Комментарий..."
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Позиции ({items.length})
|
||||||
|
</Text>
|
||||||
|
{invalidItemsCount > 0 && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12 }}>
|
||||||
|
{invalidItemsCount} нераспознано
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
<DragDropContext
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<Droppable droppableId="draft-items">
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: snapshot.isDraggingOver
|
||||||
|
? "#f0f0f0"
|
||||||
|
: "transparent",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: snapshot.isDraggingOver ? "8px" : "0",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<DraftItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onLocalUpdate={handleItemUpdate}
|
||||||
|
onDelete={(itemId) => deleteItem(itemId)}
|
||||||
|
isUpdating={false}
|
||||||
|
recommendations={recommendationsQuery.data || []}
|
||||||
|
isReordering={isReordering}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{/* Кнопка добавления позиции */}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
block
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ marginTop: 12, marginBottom: 80, height: 40 }}
|
||||||
|
onClick={() => addItemMutation.mutate()}
|
||||||
|
loading={addItemMutation.isPending}
|
||||||
|
disabled={isCanceled}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Добавить товар
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions - прижат к низу контейнера */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "#fff",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderTop: "1px solid #f0f0f0",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<Text style={{ fontSize: 11, color: "#888", lineHeight: 1 }}>
|
||||||
|
Итого:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#1890ff",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalSum.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={handleCommit}
|
||||||
|
loading={commitMutation.isPending}
|
||||||
|
disabled={invalidItemsCount > 0 || isCanceled}
|
||||||
|
style={{ height: 36, padding: "0 20px" }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isCanceled ? "Восстановить" : "Отправить"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Скрытый компонент для просмотра изображения */}
|
||||||
|
{draft.photo_url && (
|
||||||
|
<div style={{ display: "none" }}>
|
||||||
|
<Image.PreviewGroup
|
||||||
|
preview={{
|
||||||
|
visible: previewVisible,
|
||||||
|
onVisibleChange: (vis) => setPreviewVisible(vis),
|
||||||
|
movable: true,
|
||||||
|
scaleStep: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image src={getStaticUrl(draft.photo_url)} />
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для просмотра Excel файлов */}
|
||||||
|
<ExcelPreviewModal
|
||||||
|
visible={excelPreviewVisible}
|
||||||
|
onCancel={() => setExcelPreviewVisible(false)}
|
||||||
|
fileUrl={draft.photo_url ? getStaticUrl(draft.photo_url) : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
217
rmser-view/src/components/invoices/DraftVerificationModal.tsx
Normal file
217
rmser-view/src/components/invoices/DraftVerificationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
263
rmser-view/src/components/invoices/InvoiceViewer.tsx
Normal file
263
rmser-view/src/components/invoices/InvoiceViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} // Передаем полный объект для правильного отображения!
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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 (
|
return (
|
||||||
<div style={{ paddingBottom: 20 }}>
|
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||||
{/* Header */}
|
{invoiceId && (
|
||||||
<div
|
<InvoiceViewer
|
||||||
style={{
|
invoiceId={invoiceId}
|
||||||
marginBottom: 16,
|
onBack={() => navigate("/invoices")}
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: "В работе",
|
|
||||||
},
|
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||||
{
|
setIsDragOver(false);
|
||||||
id: "2",
|
}
|
||||||
title: "Черновик #2",
|
};
|
||||||
date: "2024-01-14",
|
|
||||||
status: "Черновик",
|
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 (
|
||||||
|
<div>
|
||||||
|
<Title level={2}>Черновики</Title>
|
||||||
|
<Card>
|
||||||
|
<Empty
|
||||||
|
description="Выберите сервер сверху"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<Title level={2}>Черновики</Title>
|
<Title level={2}>Черновики</Title>
|
||||||
|
|
||||||
{/* Зона для загрузки файлов */}
|
{/* Список черновиков */}
|
||||||
<Card style={{ marginBottom: "24px" }}>
|
<Card title="Последние черновики" loading={isLoading}>
|
||||||
<DragDropZone onDrop={handleDrop} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Список черновиков (заглушка) */}
|
|
||||||
<Card title="Последние черновики">
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +193,8 @@ export interface DraftItem {
|
|||||||
|
|
||||||
// Мета-данные
|
// Мета-данные
|
||||||
is_matched: boolean;
|
is_matched: boolean;
|
||||||
product?: CatalogItem;
|
product?: CatalogItem | null;
|
||||||
container?: ProductContainer;
|
container?: ProductContainer;
|
||||||
|
|
||||||
// Поля для синхронизации состояния (опционально, если бэкенд их отдает)
|
// Поля для синхронизации состояния (опционально, если бэкенд их отдает)
|
||||||
last_edited_field_1?: string;
|
last_edited_field_1?: string;
|
||||||
@@ -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;
|
||||||
|
|||||||
185
rmser-view/src/stores/activeDraftStore.ts
Normal file
185
rmser-view/src/stores/activeDraftStore.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
54
rmser-view/src/stores/serverStore.ts
Normal file
54
rmser-view/src/stores/serverStore.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
66
rmser-view/src/utils/calculations.ts
Normal file
66
rmser-view/src/utils/calculations.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user