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:
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -132,12 +132,14 @@ const AppContent = () => {
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<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="*" element={<Navigate to="/" replace />} />
|
||||
</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={<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 {
|
||||
Card,
|
||||
@@ -23,7 +23,6 @@ import { CatalogSelect } from "../ocr/CatalogSelect";
|
||||
import { CreateContainerModal } from "./CreateContainerModal";
|
||||
import type {
|
||||
DraftItem,
|
||||
UpdateDraftItemRequest,
|
||||
ProductSearchResult,
|
||||
ProductContainer,
|
||||
Recommendation,
|
||||
@@ -31,22 +30,20 @@ import type {
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
interface DraftItemRowProps {
|
||||
item: DraftItem;
|
||||
index: number;
|
||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||
onLocalUpdate: (id: string, changes: Partial<DraftItem>) => void;
|
||||
onDelete: (itemId: string) => void;
|
||||
isUpdating: boolean;
|
||||
recommendations?: Recommendation[];
|
||||
isReordering: boolean;
|
||||
}
|
||||
|
||||
type FieldType = "quantity" | "price" | "sum";
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({
|
||||
export const DraftItemRow: React.FC<DraftItemRowProps> = ({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onLocalUpdate,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
recommendations = [],
|
||||
@@ -54,151 +51,14 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
}) => {
|
||||
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(() => {
|
||||
if (searchedProduct && searchedProduct.id === item.product_id)
|
||||
return searchedProduct;
|
||||
return item.product as unknown as ProductSearchResult | undefined;
|
||||
}, [searchedProduct, item.product, item.product_id]);
|
||||
}, [item.product]);
|
||||
|
||||
const containers = useMemo(() => {
|
||||
if (!activeProduct) return [];
|
||||
const baseContainers = activeProduct.containers || [];
|
||||
const manuallyAdded = addedContainers[activeProduct.id] || [];
|
||||
const combined = [...baseContainers];
|
||||
manuallyAdded.forEach((c) => {
|
||||
if (!combined.find((existing) => existing.id === c.id)) combined.push(c);
|
||||
});
|
||||
return combined;
|
||||
}, [activeProduct, addedContainers]);
|
||||
return activeProduct.containers || [];
|
||||
}, [activeProduct]);
|
||||
|
||||
const baseUom =
|
||||
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
|
||||
@@ -255,34 +115,41 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
|
||||
// --- Handlers ---
|
||||
const handleProductChange = (
|
||||
prodId: string,
|
||||
prodId: string | null,
|
||||
productObj?: ProductSearchResult
|
||||
) => {
|
||||
if (productObj) setSearchedProduct(productObj);
|
||||
onUpdate(item.id, {
|
||||
onLocalUpdate(item.id, {
|
||||
product_id: prodId,
|
||||
container_id: null, // Сбрасываем фасовку
|
||||
// При смене товара логично оставить Qty и Sum, пересчитав Price?
|
||||
// Или оставить Qty и Price? Обычно цена меняется.
|
||||
// Пока не трогаем числа, пусть остаются как были.
|
||||
product: prodId ? productObj : null,
|
||||
container_id: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContainerChange = (val: string) => {
|
||||
// "" пустая строка приходит при выборе "Базовая" (мы так настроим value)
|
||||
const newVal = val === "BASE_UNIT" ? "" : val;
|
||||
onUpdate(item.id, { container_id: newVal });
|
||||
onLocalUpdate(item.id, {
|
||||
container_id: newVal,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContainerCreated = (newContainer: ProductContainer) => {
|
||||
setIsModalOpen(false);
|
||||
if (activeProduct) {
|
||||
setAddedContainers((prev) => ({
|
||||
...prev,
|
||||
[activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer],
|
||||
}));
|
||||
onLocalUpdate(item.id, { container_id: newContainer.id });
|
||||
};
|
||||
|
||||
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
|
||||
@@ -293,7 +160,11 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable draggableId={item.id} index={index} isDragDisabled={!isReordering}>
|
||||
<Draggable
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
isDragDisabled={!isReordering}
|
||||
>
|
||||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
marginBottom: "8px",
|
||||
@@ -420,7 +291,7 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
</Flex>
|
||||
|
||||
<CatalogSelect
|
||||
value={item.product_id || undefined}
|
||||
value={item.product_id || null}
|
||||
onChange={handleProductChange}
|
||||
initialProduct={activeProduct}
|
||||
/>
|
||||
@@ -476,10 +347,13 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
min={0}
|
||||
value={localQty}
|
||||
value={item.quantity}
|
||||
onChange={(val) => handleValueChange("quantity", val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
onBlur={() => handleBlur()}
|
||||
precision={3}
|
||||
parser={(value) =>
|
||||
value?.replace(",", ".") as unknown as number
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber
|
||||
@@ -487,10 +361,13 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
controls={false}
|
||||
placeholder="Цена"
|
||||
min={0}
|
||||
value={localPrice}
|
||||
value={item.price}
|
||||
onChange={(val) => handleValueChange("price", val)}
|
||||
onBlur={() => handleBlur("price")}
|
||||
onBlur={() => handleBlur()}
|
||||
precision={2}
|
||||
parser={(value) =>
|
||||
value?.replace(",", ".") as unknown as number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -503,10 +380,13 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
controls={false}
|
||||
placeholder="Сумма"
|
||||
min={0}
|
||||
value={localSum}
|
||||
value={item.sum}
|
||||
onChange={(val) => handleValueChange("sum", val)}
|
||||
onBlur={() => handleBlur("sum")}
|
||||
onBlur={() => handleBlur()}
|
||||
precision={2}
|
||||
parser={(value) =>
|
||||
value?.replace(",", ".") as unknown as number
|
||||
}
|
||||
/>
|
||||
</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 = (
|
||||
val: string,
|
||||
val: string | null,
|
||||
productObj?: ProductSearchResult
|
||||
) => {
|
||||
setSelectedProduct(val);
|
||||
setSelectedProduct(val || undefined);
|
||||
if (productObj) {
|
||||
setSelectedProductData(productObj);
|
||||
}
|
||||
@@ -282,7 +282,7 @@ export const AddMatchForm: React.FC<Props> = ({
|
||||
Товар в iiko:
|
||||
</div>
|
||||
<CatalogSelect
|
||||
value={selectedProduct}
|
||||
value={selectedProduct || null}
|
||||
onChange={handleProductChange}
|
||||
disabled={isLoading}
|
||||
initialProduct={activeProduct} // Передаем полный объект для правильного отображения!
|
||||
|
||||
@@ -4,8 +4,8 @@ import { api } from "../../services/api";
|
||||
import type { CatalogItem, ProductSearchResult } from "../../services/types";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string, productObj?: ProductSearchResult) => void;
|
||||
value: string | null;
|
||||
onChange: (value: string | null, productObj?: ProductSearchResult) => void;
|
||||
disabled?: boolean;
|
||||
initialProduct?: CatalogItem | ProductSearchResult;
|
||||
}
|
||||
@@ -85,12 +85,12 @@ export const CatalogSelect: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
val: string,
|
||||
val: string | undefined,
|
||||
option: SelectOption | SelectOption[] | undefined
|
||||
) => {
|
||||
if (onChange) {
|
||||
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
|
||||
}
|
||||
options={options}
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
style={{ width: "100%" }}
|
||||
@@ -118,6 +118,7 @@ export const CatalogSelect: React.FC<Props> = ({
|
||||
onClear={() => {
|
||||
setOptions([]);
|
||||
setNotFound(false);
|
||||
onChange(null);
|
||||
}}
|
||||
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
|
||||
onFocus={() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Layout, Space, Avatar, Dropdown, Button } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import React, { useEffect } from "react";
|
||||
import { Layout, Space, Avatar, Dropdown, Select } from "antd";
|
||||
import { UserOutlined, LogoutOutlined } from "@ant-design/icons";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useServerStore } from "../../stores/serverStore";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -11,16 +12,27 @@ const { Header } = Layout;
|
||||
*/
|
||||
export const DesktopHeader: React.FC = () => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { servers, activeServer, isLoading, fetchServers, setActiveServer } =
|
||||
useServerStore();
|
||||
|
||||
// Загружаем список серверов при маунте компонента
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = '/web';
|
||||
window.location.href = "/web";
|
||||
};
|
||||
|
||||
const handleServerChange = (serverId: string) => {
|
||||
setActiveServer(serverId);
|
||||
};
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'logout',
|
||||
label: 'Выйти',
|
||||
key: "logout",
|
||||
label: "Выйти",
|
||||
icon: <LogoutOutlined />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
@@ -29,55 +41,65 @@ export const DesktopHeader: React.FC = () => {
|
||||
return (
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
padding: '0 24px',
|
||||
height: '64px',
|
||||
position: 'fixed',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: "#ffffff",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
|
||||
padding: "0 24px",
|
||||
height: "64px",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "24px" }}>
|
||||
{/* Логотип */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1890ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
color: "#1890ff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span>RMSer</span>
|
||||
</div>
|
||||
|
||||
{/* Заглушка выбора сервера */}
|
||||
<Button
|
||||
type="default"
|
||||
ghost
|
||||
style={{
|
||||
color: '#8c8c8c',
|
||||
borderColor: '#d9d9d9',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
Сервер не выбран
|
||||
</Button>
|
||||
{/* Выбор сервера */}
|
||||
<Select
|
||||
placeholder="Выберите сервер"
|
||||
value={activeServer?.id || undefined}
|
||||
onChange={handleServerChange}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
style={{ minWidth: "200px" }}
|
||||
options={servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Аватар пользователя */}
|
||||
<Space>
|
||||
<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 />} />
|
||||
<span style={{ color: '#262626' }}>{user?.username || 'Пользователь'}</span>
|
||||
<span style={{ color: "#262626" }}>
|
||||
{user?.username || "Пользователь"}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
||||
@@ -1,651 +1,18 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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;
|
||||
import { DraftEditor } from "../components/invoices/DraftEditor";
|
||||
|
||||
export const InvoiceDraftPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id: draftId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
const [itemsOrder, setItemsOrder] = useState<Record<string, number>>({});
|
||||
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="Ошибка загрузки черновика" />;
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 60 }}>
|
||||
{/* Header */}
|
||||
<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 style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<DraftEditor draftId={draftId} onBack={() => navigate("/invoices")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,211 +1,19 @@
|
||||
// src/pages/InvoiceViewPage.tsx
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
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 { api, getStaticUrl } from "../services/api";
|
||||
import type { DraftStatus } from "../services/types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
import { InvoiceViewer } from "../components/invoices/InvoiceViewer";
|
||||
|
||||
export const InvoiceViewPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id: invoiceId } = useParams<{ id: string }>();
|
||||
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 (
|
||||
<div style={{ paddingBottom: 20 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/invoices")}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
№{invoice.number}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{invoice.date} • {invoice.supplier.name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{getStatusTag(invoice.status)}
|
||||
|
||||
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||
{invoice.photo_url && (
|
||||
<Button
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => setPreviewVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
Чек
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица товаров */}
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>
|
||||
Товары ({(invoice.items || []).length} поз.)
|
||||
</Title>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={invoice.items || []}
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={2}>
|
||||
<Text strong>Итого:</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right">
|
||||
<Text strong>
|
||||
{totalSum.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
})}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
{invoiceId && (
|
||||
<InvoiceViewer
|
||||
invoiceId={invoiceId}
|
||||
onBack={() => navigate("/invoices")}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import { Typography, Card, List, Empty } from "antd";
|
||||
import { DragDropZone } from "../../../components/DragDropZone";
|
||||
import React, { useState } from "react";
|
||||
import { Typography, Card, List, Empty, Tag, Spin } from "antd";
|
||||
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;
|
||||
|
||||
@@ -9,45 +14,129 @@ const { Title } = Typography;
|
||||
* Содержит зону для загрузки файлов и список черновиков
|
||||
*/
|
||||
export const InvoicesDashboard: React.FC = () => {
|
||||
const handleDrop = (files: File[]) => {
|
||||
console.log("Файлы загружены:", files);
|
||||
// TODO: Добавить логику обработки файлов
|
||||
const { activeServer } = useServerStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Состояние для 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 mockDrafts = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Черновик #1",
|
||||
date: "2024-01-15",
|
||||
status: "В работе",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Черновик #2",
|
||||
date: "2024-01-14",
|
||||
status: "Черновик",
|
||||
},
|
||||
];
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Проверяем, что мы действительно покидаем элемент, а не просто переходим к дочернему
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Разрешаем drop
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Берем только первый файл
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const draft = await api.uploadFile(file);
|
||||
|
||||
// Обновляем список черновиков
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts", activeServer?.id] });
|
||||
|
||||
// Открываем модальное окно проверки
|
||||
setVerificationDraftId(draft.id);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки файла:", error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseVerification = () => {
|
||||
setVerificationDraftId(null);
|
||||
};
|
||||
|
||||
// Если сервер не выбран, показываем сообщение
|
||||
if (!activeServer) {
|
||||
return (
|
||||
<div>
|
||||
<Title level={2}>Черновики</Title>
|
||||
<Card>
|
||||
<Empty
|
||||
description="Выберите сервер сверху"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{ position: "relative" }}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Title level={2}>Черновики</Title>
|
||||
|
||||
{/* Зона для загрузки файлов */}
|
||||
<Card style={{ marginBottom: "24px" }}>
|
||||
<DragDropZone onDrop={handleDrop} />
|
||||
</Card>
|
||||
|
||||
{/* Список черновиков (заглушка) */}
|
||||
<Card title="Последние черновики">
|
||||
{/* Список черновиков */}
|
||||
<Card title="Последние черновики" loading={isLoading}>
|
||||
<List
|
||||
dataSource={mockDrafts}
|
||||
renderItem={(draft) => (
|
||||
dataSource={drafts || []}
|
||||
renderItem={(draft: UnifiedInvoice) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={draft.title}
|
||||
description={`${draft.date} • ${draft.status}`}
|
||||
title={
|
||||
<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>
|
||||
)}
|
||||
@@ -61,6 +150,65 @@ export const InvoicesDashboard: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,8 @@ import type {
|
||||
ServerUser,
|
||||
UserRole,
|
||||
InvoiceDetails,
|
||||
GetPhotosResponse
|
||||
GetPhotosResponse,
|
||||
ServerShort
|
||||
} from './types';
|
||||
|
||||
// Интерфейс для ответа метода инициализации десктопной авторизации
|
||||
@@ -212,6 +213,11 @@ export const api = {
|
||||
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> => {
|
||||
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
||||
return data;
|
||||
@@ -314,5 +320,31 @@ export const api = {
|
||||
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
|
||||
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 interface ServerShort {
|
||||
id: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// Интерфейс пользователя сервера
|
||||
export interface ServerUser {
|
||||
user_id: string;
|
||||
@@ -185,8 +193,8 @@ export interface DraftItem {
|
||||
|
||||
// Мета-данные
|
||||
is_matched: boolean;
|
||||
product?: CatalogItem;
|
||||
container?: ProductContainer;
|
||||
product?: CatalogItem | null;
|
||||
container?: ProductContainer;
|
||||
|
||||
// Поля для синхронизации состояния (опционально, если бэкенд их отдает)
|
||||
last_edited_field_1?: string;
|
||||
@@ -221,7 +229,7 @@ export interface DraftInvoice {
|
||||
|
||||
// DTO для обновления строки
|
||||
export interface UpdateDraftItemRequest {
|
||||
product_id?: UUID;
|
||||
product_id?: UUID | null;
|
||||
container_id?: UUID | null;
|
||||
quantity?: 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