diff --git a/cmd/main.go b/cmd/main.go index 77bf36e..808f3fb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -117,12 +117,13 @@ func main() { authService := auth.NewService(accountRepo, wsServer, cfg.Security.SecretKey) // 9. Handlers - draftsHandler := handlers.NewDraftsHandler(draftsService) + draftsHandler := handlers.NewDraftsHandler(draftsService, ocrService) billingHandler := handlers.NewBillingHandler(billingService) ocrHandler := handlers.NewOCRHandler(ocrService) photosHandler := handlers.NewPhotosHandler(photosService) recommendHandler := handlers.NewRecommendationsHandler(recService) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) + settingsHandler.SetRMSFactory(rmsFactory) invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService) authHandler := handlers.NewAuthHandler(authService, cfg.Telegram.BotUsername) @@ -172,6 +173,7 @@ func main() { api.GET("/drafts", draftsHandler.GetDrafts) api.GET("/drafts/:id", draftsHandler.GetDraft) api.DELETE("/drafts/:id", draftsHandler.DeleteDraft) + api.POST("/drafts/upload", draftsHandler.Upload) // Items CRUD api.POST("/drafts/:id/items", draftsHandler.AddDraftItem) api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem) @@ -183,6 +185,9 @@ func main() { // Settings api.GET("/settings", settingsHandler.GetSettings) api.POST("/settings", settingsHandler.UpdateSettings) + // User Servers + api.GET("/user/servers", settingsHandler.GetUserServers) + api.POST("/user/servers/active", settingsHandler.SwitchActiveServer) // Photos Storage api.GET("/photos", photosHandler.GetPhotos) api.DELETE("/photos/:id", photosHandler.DeletePhoto) diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index b27d46a..a67bcec 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "io" "net/http" "time" @@ -11,15 +12,20 @@ import ( "go.uber.org/zap" "rmser/internal/services/drafts" + "rmser/internal/services/ocr" "rmser/pkg/logger" ) type DraftsHandler struct { - service *drafts.Service + service *drafts.Service + ocrService *ocr.Service } -func NewDraftsHandler(service *drafts.Service) *DraftsHandler { - return &DraftsHandler{service: service} +func NewDraftsHandler(service *drafts.Service, ocrService *ocr.Service) *DraftsHandler { + return &DraftsHandler{ + service: service, + ocrService: ocrService, + } } 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}) } + +// 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) +} diff --git a/internal/transport/http/handlers/settings.go b/internal/transport/http/handlers/settings.go index 6f3a4db..19a6007 100644 --- a/internal/transport/http/handlers/settings.go +++ b/internal/transport/http/handlers/settings.go @@ -22,6 +22,11 @@ type SettingsHandler struct { accountRepo account.Repository catalogRepo catalog.Repository notifier Notifier // Поле для отправки уведомлений + rmsFactory RMSFactory +} + +type RMSFactory interface { + ClearCacheForUser(userID uuid.UUID) } 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 используется для внедрения зависимости после инициализации func (h *SettingsHandler) SetNotifier(n Notifier) { h.notifier = n @@ -388,3 +398,107 @@ func (h *SettingsHandler) RemoveUser(c *gin.Context) { 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"}) +} diff --git a/rmser-view/dump_react.py b/rmser-view/dump_react.py deleted file mode 100644 index c166a94..0000000 --- a/rmser-view/dump_react.py +++ /dev/null @@ -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("<<>>") - blocks.append(f"path: {rel}") - blocks.append("error: не удалось прочитать файл") - blocks.append(f"exception: {type(e).__name__}: {e}") - blocks.append("<<>>") - 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("<<>>") - 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("<<>>") - blocks.append(text) - blocks.append("<<>>") - 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() \ No newline at end of file diff --git a/rmser-view/package-lock.json b/rmser-view/package-lock.json index d27543d..e33b581 100644 --- a/rmser-view/package-lock.json +++ b/rmser-view/package-lock.json @@ -14,12 +14,14 @@ "antd": "^6.1.0", "axios": "^1.13.2", "clsx": "^2.1.1", + "immer": "^11.1.3", "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", + "xlsx": "^0.18.5", "zustand": "^5.0.9" }, "devDependencies": { @@ -2636,6 +2638,15 @@ "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": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2873,6 +2884,19 @@ ], "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": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2899,6 +2923,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2964,6 +2997,18 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3489,6 +3534,15 @@ "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3674,6 +3728,17 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4252,9 +4317,9 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4274,12 +4339,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", - "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.10.1" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -4412,6 +4477,18 @@ "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": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -4702,6 +4779,24 @@ "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4712,6 +4807,27 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/rmser-view/package.json b/rmser-view/package.json index c4d0a23..d4d72cc 100644 --- a/rmser-view/package.json +++ b/rmser-view/package.json @@ -16,12 +16,14 @@ "antd": "^6.1.0", "axios": "^1.13.2", "clsx": "^2.1.1", + "immer": "^11.1.3", "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", "react-router-dom": "^7.10.1", + "xlsx": "^0.18.5", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index a07a3ea..da8cf17 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -132,12 +132,14 @@ const AppContent = () => { } /> } /> } /> - } /> - } /> } /> } /> + {/* Роуты для детальных страниц накладных (без AppLayout - на весь экран) */} + } /> + } /> + {/* Десктопные роуты */} } /> }> diff --git a/rmser-view/src/components/common/ExcelPreviewModal.tsx b/rmser-view/src/components/common/ExcelPreviewModal.tsx new file mode 100644 index 0000000..1a6f8d7 --- /dev/null +++ b/rmser-view/src/components/common/ExcelPreviewModal.tsx @@ -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 = ({ + visible, + onCancel, + fileUrl, +}) => { + // Данные таблицы из Excel файла + const [data, setData] = useState< + (string | number | boolean | null | undefined)[][] + >([]); + // Масштаб отображения таблицы + const [scale, setScale] = useState(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 ( + + {/* Панель инструментов для управления масштабом */} +
+ + + + + Масштаб: {Math.round(scale * 100)}% + +
+ + {/* Контейнер с прокруткой для таблицы */} +
+ {/* Таблица с данными Excel */} + + + {data.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {cell !== undefined && cell !== null ? String(cell) : ""} +
+
+
+ ); +}; + +export default ExcelPreviewModal; diff --git a/rmser-view/src/components/invoices/DraftEditor.tsx b/rmser-view/src/components/invoices/DraftEditor.tsx new file mode 100644 index 0000000..27a3aea --- /dev/null +++ b/rmser-view/src/components/invoices/DraftEditor.tsx @@ -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 = ({ + draftId, + onBack, +}) => { + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + + // Zustand стор для локального управления элементами черновика + const { + items, + isDirty, + setItems, + updateItem, + deleteItem, + addItem, + reorderItems, + resetDirty, + } = useActiveDraftStore(); + + // Отслеживаем текущий draftId для инициализации стора + const currentDraftIdRef = useRef(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 = { + 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) => { + // Обновляем локально через стор + 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: , + content: + "У вас есть несохраненные изменения. Сохранить их перед выходом?", + okText: "Сохранить и выйти", + cancelText: "Выйти без сохранения", + onOk: async () => { + try { + await saveChanges(); + onBack?.(); + } catch { + // Ошибка уже обработана в saveChanges + } + }, + onCancel: () => { + onBack?.(); + }, + }); + } else { + onBack?.(); + } + }; + + const handleDelete = () => { + confirm({ + title: isCanceled ? "Удалить окончательно?" : "Отменить черновик?", + icon: , + 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 ( +
+ +
+ ); + } + + if (draftQuery.isError || !draft) { + return ; + } + + return ( +
+ {/* Единый хедер */} +
+ {/* Левая часть: Кнопка назад + Номер + Статус */} +
+ {onBack && ( +
+ + {/* Правая часть: Кнопки действий */} +
+ {/* Кнопка сохранения (показывается только если есть несохраненные изменения) */} + {isDirty && ( + + )} + + {/* Кнопка просмотра чека (только если есть URL) */} + {draft.photo_url && ( +
+
+ + {/* Основная часть с прокруткой */} +
+ {/* Form: Склады и Поставщики */} +
+
+ + + + + + + + + + + + + + + + ({ label: s.name, value: s.id }))} + size="small" + showSearch + filterOption={(input, option) => + (option?.label ?? "") + .toLowerCase() + .includes(input.toLowerCase()) + } + /> + + +