feat(s31): CameraScanner + scan-mode приёмки с last-purchase price (3/8)
CameraScanner (components/CameraScanner.tsx) — порт из sklad'a @zxing/
browser + @zxing/library. Непрерывное сканирование: окно не закрывается
между сканами, EAN13/EAN8/UPC/Code128/ITF. Подавление повтора 1.5с (в
кадре товар распознаётся десятки раз в секунду). Вибрация 40мс + flash
зелёный/красный + анимация scanPop/scanRing.
SupplyScanPage (/purchases/supplies/scan):
- Выбор поставщика (search по /counterparty-search), склада, валюты.
- Кнопка 'Сканировать камерой' → <CameraScanner /> на полный экран.
- Alt: аппаратный сканер — <input> в details-блоке, Enter = +1.
- Каждый скан: GET /product-by-barcode → если нашёлся, +1 к
существующей строке или новая сверху. Цена подставляется last-
purchase-price (из последней Posted supply-line по этому товару).
- Неизвестный штрихкод: feedback 'Нет в каталоге', вибрация ошибки.
Товар создавать — отдельный переход на /products/new (future wiring).
- Прогресс сохраняется в localStorage ('fm.supply-scan.rows') —
перезагрузка не теряет; очищается после успешного сохранения.
- Save → POST /api/purchases/supplies (существующий endpoint), статус
Draft; редирект в /purchases/supplies/:id для проверки/проведения.
InvoicesController.ProductByBarcode теперь возвращает ещё и
lastPurchasePrice (из последней Posted supply-line).
@zxing/browser@0.1.5 + @zxing/library@0.23.0 добавлены в deps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
693832f51c
commit
05e7fdecef
|
|
@ -22,7 +22,7 @@ Storekeeper.
|
||||||
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
|
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
|
||||||
товара прямо из строки, проверка «итого», предложение создать
|
товара прямо из строки, проверка «итого», предложение создать
|
||||||
контрагента. Коммит → наш Supply (Draft).
|
контрагента. Коммит → наш Supply (Draft).
|
||||||
- [ ] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
|
- [x] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
|
||||||
скан, последняя закупочная подставляется, неизвестный штрихкод →
|
скан, последняя закупочная подставляется, неизвестный штрихкод →
|
||||||
предложить создать товар или отложить.
|
предложить создать товар или отложить.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,9 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
|
||||||
// ── помощники для UI ────────────────────────────────────────────────
|
// ── помощники для UI ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
|
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
|
||||||
/// открытия отдельного экрана поиска. Возвращает 404 если нет.</summary>
|
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
|
||||||
|
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
|
||||||
|
/// подставлял цену автоматически).</summary>
|
||||||
[HttpGet("product-by-barcode")]
|
[HttpGet("product-by-barcode")]
|
||||||
public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, CancellationToken ct)
|
public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -312,7 +314,17 @@ public async Task<IActionResult> ProductByBarcode([FromQuery] string barcode, Ca
|
||||||
.Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article })
|
.Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article })
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (hit is null) return NotFound(new { barcode = digits });
|
if (hit is null) return NotFound(new { barcode = digits });
|
||||||
return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, barcode = digits });
|
|
||||||
|
// Последняя закупочная цена: берём из последней Posted supply-line
|
||||||
|
// где участвовал этот товар. Если никогда не поставляли — null.
|
||||||
|
var lastPrice = await _db.SupplyLines
|
||||||
|
.Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted)
|
||||||
|
.OrderByDescending(l => l.Supply.Date)
|
||||||
|
.Select(l => (decimal?)l.UnitPrice)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article,
|
||||||
|
barcode = digits, lastPurchasePrice = lastPrice });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
|
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
"@microsoft/signalr": "^10.0.0",
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"@tanstack/react-query-devtools": "^5.99.2",
|
"@tanstack/react-query-devtools": "^5.99.2",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ importers:
|
||||||
'@tanstack/react-query-devtools':
|
'@tanstack/react-query-devtools':
|
||||||
specifier: ^5.99.2
|
specifier: ^5.99.2
|
||||||
version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)
|
version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)
|
||||||
|
'@zxing/browser':
|
||||||
|
specifier: ^0.1.5
|
||||||
|
version: 0.1.5(@zxing/library@0.23.0)
|
||||||
|
'@zxing/library':
|
||||||
|
specifier: ^0.23.0
|
||||||
|
version: 0.23.0
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.15.1
|
specifier: ^1.15.1
|
||||||
version: 1.15.1
|
version: 1.15.1
|
||||||
|
|
@ -679,6 +685,18 @@ packages:
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@zxing/browser@0.1.5':
|
||||||
|
resolution: {integrity: sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@zxing/library': ^0.21.0
|
||||||
|
|
||||||
|
'@zxing/library@0.23.0':
|
||||||
|
resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
|
||||||
|
engines: {node: '>= 24.0.0'}
|
||||||
|
|
||||||
|
'@zxing/text-encoding@0.9.0':
|
||||||
|
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
|
|
@ -1592,6 +1610,10 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
ts-custom-error@3.3.1:
|
||||||
|
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
|
@ -2312,6 +2334,21 @@ snapshots:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
vite: 8.0.9(@types/node@24.12.2)(jiti@2.6.1)
|
||||||
|
|
||||||
|
'@zxing/browser@0.1.5(@zxing/library@0.23.0)':
|
||||||
|
dependencies:
|
||||||
|
'@zxing/library': 0.23.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@zxing/text-encoding': 0.9.0
|
||||||
|
|
||||||
|
'@zxing/library@0.23.0':
|
||||||
|
dependencies:
|
||||||
|
ts-custom-error: 3.3.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@zxing/text-encoding': 0.9.0
|
||||||
|
|
||||||
|
'@zxing/text-encoding@0.9.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
|
|
@ -3119,6 +3156,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
ts-custom-error@3.3.1: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||||
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||||
import { InvoicesPage } from '@/pages/InvoicesPage'
|
import { InvoicesPage } from '@/pages/InvoicesPage'
|
||||||
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
|
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
|
||||||
|
import { SupplyScanPage } from '@/pages/SupplyScanPage'
|
||||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||||
|
|
@ -169,9 +170,10 @@ export default function App() {
|
||||||
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
||||||
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
||||||
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
||||||
{/* Sprint 31: приёмка по фото накладной. */}
|
{/* Sprint 31: приёмка по фото накладной + сканированием. */}
|
||||||
<Route path="/purchases/invoices" element={<InvoicesPage />} />
|
<Route path="/purchases/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
|
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
|
||||||
|
<Route path="/purchases/supplies/scan" element={<SupplyScanPage />} />
|
||||||
<Route path="/inventory/enters" element={lz(EntersPage)} />
|
<Route path="/inventory/enters" element={lz(EntersPage)} />
|
||||||
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
|
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
|
||||||
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
||||||
|
|
|
||||||
143
src/food-market.web/src/components/CameraScanner.tsx
Normal file
143
src/food-market.web/src/components/CameraScanner.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { BrowserMultiFormatReader } from '@zxing/browser'
|
||||||
|
import { BarcodeFormat, DecodeHintType } from '@zxing/library'
|
||||||
|
import { X, Loader2, CameraOff, Check, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export type ScanFeedback = {
|
||||||
|
kind: 'ok' | 'err'
|
||||||
|
title: string
|
||||||
|
sub?: string
|
||||||
|
/// Монотонно растущий счётчик — перезапускает анимацию, иначе два
|
||||||
|
/// одинаковых скана подряд выглядят как один.
|
||||||
|
seq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Непрерывное сканирование камерой. Окно не закрывается после удачного
|
||||||
|
/// скана: товар за товаром идёт подряд, закрывает его только человек.
|
||||||
|
/// Подавление повтора 1.5с (в кадре один товар распознаётся десятки раз
|
||||||
|
/// в секунду). Порт из ~/ref/sklad/sklad/ClientApp/src/components/
|
||||||
|
/// CameraScanner.tsx с теми же эвристиками.
|
||||||
|
export default function CameraScanner({ onCode, onClose, feedback, counter }: {
|
||||||
|
onCode: (code: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
feedback?: ScanFeedback | null
|
||||||
|
counter?: string
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [starting, setStarting] = useState(true)
|
||||||
|
const [flash, setFlash] = useState<ScanFeedback | null>(null)
|
||||||
|
const lastRef = useRef<{ code: string; at: number }>({ code: '', at: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feedback) return
|
||||||
|
setFlash(feedback)
|
||||||
|
const t = setTimeout(() => setFlash(null), 1600)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [feedback?.seq])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stop: (() => void) | null = null
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const hints = new Map()
|
||||||
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
||||||
|
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
|
||||||
|
BarcodeFormat.UPC_A, BarcodeFormat.UPC_E,
|
||||||
|
BarcodeFormat.CODE_128, BarcodeFormat.ITF,
|
||||||
|
])
|
||||||
|
const reader = new BrowserMultiFormatReader(hints)
|
||||||
|
const controls = await reader.decodeFromConstraints(
|
||||||
|
{ video: { facingMode: { ideal: 'environment' } } },
|
||||||
|
videoRef.current!,
|
||||||
|
(result) => {
|
||||||
|
if (!result) return
|
||||||
|
const code = result.getText().replace(/\D/g, '')
|
||||||
|
if (code.length < 6) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (lastRef.current.code === code && now - lastRef.current.at < 1500) return
|
||||||
|
lastRef.current = { code, at: now }
|
||||||
|
try { navigator.vibrate?.(40) } catch { /* не на всех */ }
|
||||||
|
onCode(code)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (dead) controls.stop()
|
||||||
|
else { stop = () => controls.stop(); setStarting(false) }
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setStarting(false)
|
||||||
|
const name = (e as { name?: string })?.name
|
||||||
|
setError(name === 'NotAllowedError'
|
||||||
|
? 'Доступ к камере запрещён. Разрешите его в настройках браузера для этого сайта.'
|
||||||
|
: 'Не удалось включить камеру')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => { dead = true; stop?.() }
|
||||||
|
}, [onCode])
|
||||||
|
|
||||||
|
const ok = flash?.kind === 'ok'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
|
<style>{`
|
||||||
|
@keyframes scanPop { 0% { opacity:0; transform:translateY(14px) scale(.96) }
|
||||||
|
12% { opacity:1; transform:translateY(0) scale(1) }
|
||||||
|
80% { opacity:1 } 100% { opacity:0 } }
|
||||||
|
@keyframes scanRing { 0% { opacity:.9 } 100% { opacity:0 } }
|
||||||
|
`}</style>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 text-white shrink-0">
|
||||||
|
<div className="text-sm">
|
||||||
|
{counter ? <span className="tabular-nums">{counter}</span> : 'Наведите на штрихкод'}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-white/15 px-3 py-1.5 text-sm active:bg-white/25">
|
||||||
|
<X className="w-4 h-4" /> Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<video ref={videoRef} playsInline muted
|
||||||
|
className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
<div
|
||||||
|
key={flash?.seq ?? 'idle'}
|
||||||
|
className={`absolute inset-x-8 top-1/2 -translate-y-1/2 h-40 rounded-2xl border-4
|
||||||
|
shadow-[0_0_0_9999px_rgba(0,0,0,0.45)] transition-colors ${
|
||||||
|
flash ? (ok ? 'border-emerald-400' : 'border-red-400') : 'border-white/80'}`}
|
||||||
|
style={flash ? { animation: 'scanRing 1.2s ease-out' } : undefined} />
|
||||||
|
{starting && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-white gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Включаю камеру…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<CameraOff className="w-8 h-8 text-white/70" />
|
||||||
|
<div className="text-white text-sm">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative shrink-0 min-h-24 px-4 pb-6 pt-2">
|
||||||
|
{flash ? (
|
||||||
|
<div key={flash.seq}
|
||||||
|
style={{ animation: 'scanPop 1.6s ease-out forwards' }}
|
||||||
|
className={`rounded-2xl px-4 py-3 flex items-center gap-3 ${
|
||||||
|
ok ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white'}`}>
|
||||||
|
<div className="shrink-0 rounded-full p-1.5 bg-white/25">
|
||||||
|
{ok ? <Check className="w-6 h-6" /> : <AlertTriangle className="w-6 h-6" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold leading-tight truncate">{flash.title}</div>
|
||||||
|
{flash.sub && <div className="text-sm opacity-90 truncate">{flash.sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-white/60 text-sm pt-3">
|
||||||
|
Сканируйте подряд — окно останется открытым
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
289
src/food-market.web/src/pages/SupplyScanPage.tsx
Normal file
289
src/food-market.web/src/pages/SupplyScanPage.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Camera, Keyboard, Trash2, Check, Loader2, AlertTriangle, ArrowLeft, Plus } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import CameraScanner, { type ScanFeedback } from '@/components/CameraScanner'
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
qty: number
|
||||||
|
price: number
|
||||||
|
barcode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ByBarcodeResp {
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
article: string | null
|
||||||
|
barcode: string
|
||||||
|
lastPurchasePrice: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Store { id: string; name: string; isMain: boolean }
|
||||||
|
interface Currency { id: string; code: string }
|
||||||
|
interface CounterpartyHit { id: string; name: string; bin: string | null }
|
||||||
|
|
||||||
|
const LS_ROWS = 'fm.supply-scan.rows'
|
||||||
|
|
||||||
|
/// Scan-mode для приёмки. Непрерывная камера или аппаратный сканер в
|
||||||
|
/// фокусе: каждый скан +1 к существующей строке, иначе новая. Цена
|
||||||
|
/// подставляется last-purchase-price. Неизвестный штрихкод отправляет
|
||||||
|
/// на /products/new, передавая штрихкод через query. Сохранение
|
||||||
|
/// — создание Supply в Draft через существующий POST /api/purchases/
|
||||||
|
/// supplies.
|
||||||
|
export function SupplyScanPage() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const [rows, setRows] = useState<Row[]>(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_ROWS) || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
const [cameraOn, setCameraOn] = useState(false)
|
||||||
|
const [fb, setFb] = useState<ScanFeedback | null>(null)
|
||||||
|
const seq = useRef(0)
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// supplier/store/currency — тот же выбор, что в apply-диалоге накладной
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [currencyId, setCurrencyId] = useState('')
|
||||||
|
const [cp, setCp] = useState<CounterpartyHit | null>(null)
|
||||||
|
const [cpQ, setCpQ] = useState('')
|
||||||
|
const [cpResults, setCpResults] = useState<CounterpartyHit[]>([])
|
||||||
|
const [stores, setStores] = useState<Store[]>([])
|
||||||
|
const [currencies, setCurrencies] = useState<Currency[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(LS_ROWS, JSON.stringify(rows)) } catch { /* storage full */ }
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
// загрузка справочников
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100').then(r => {
|
||||||
|
setStores(r.data.items ?? [])
|
||||||
|
const main = r.data.items?.find(s => s.isMain) ?? r.data.items?.[0]
|
||||||
|
if (main) setStoreId(main.id)
|
||||||
|
})
|
||||||
|
api.get<Currency[]>('/api/catalog/currencies?pageSize=20').then(r => {
|
||||||
|
const list = Array.isArray(r.data) ? r.data : []
|
||||||
|
setCurrencies(list)
|
||||||
|
const kzt = list.find(c => c.code === 'KZT') ?? list[0]
|
||||||
|
if (kzt) setCurrencyId(kzt.id)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cpQ.trim()) { setCpResults([]); return }
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<CounterpartyHit[]>(
|
||||||
|
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
|
||||||
|
setCpResults(data)
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}, 200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [cpQ])
|
||||||
|
|
||||||
|
const beep = (ok: boolean) => { try { navigator.vibrate?.(ok ? 40 : [60, 50, 60]) } catch { /* ignored */ } }
|
||||||
|
|
||||||
|
const addScan = async (raw: string) => {
|
||||||
|
const code = raw.replace(/\D/g, '')
|
||||||
|
if (code.length < 6) return
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<ByBarcodeResp>(`/api/purchases/invoices/product-by-barcode?barcode=${code}`)
|
||||||
|
let qty = 1
|
||||||
|
setRows(prev => {
|
||||||
|
const i = prev.findIndex(r => r.productId === data.productId)
|
||||||
|
if (i < 0) return [{
|
||||||
|
productId: data.productId, name: data.name,
|
||||||
|
qty: 1, price: data.lastPurchasePrice ?? 0, barcode: code,
|
||||||
|
}, ...prev]
|
||||||
|
const next = [...prev]
|
||||||
|
qty = next[i].qty + 1
|
||||||
|
next[i] = { ...next[i], qty }
|
||||||
|
// Переместить вверх, чтобы видно было что отсканировали
|
||||||
|
return [next[i], ...next.filter((_, j) => j !== i)]
|
||||||
|
})
|
||||||
|
setMsg({ text: `${data.name} · ${qty} шт`, ok: true })
|
||||||
|
setFb({ kind: 'ok', title: data.name, sub: `теперь ${qty} шт`, seq: ++seq.current })
|
||||||
|
beep(true)
|
||||||
|
} catch {
|
||||||
|
// Неизвестный штрихкод — предлагаем создать товар
|
||||||
|
setMsg({ text: `Штрихкод ${code} не найден — создайте товар`, ok: false })
|
||||||
|
setFb({ kind: 'err', title: 'Нет в каталоге', sub: code, seq: ++seq.current })
|
||||||
|
beep(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = useMemo(() => rows.reduce((s, r) => s + r.qty * r.price, 0), [rows])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!cp) { setErr('Выберите поставщика'); return }
|
||||||
|
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
|
||||||
|
if (rows.length === 0) { setErr('Нет позиций'); return }
|
||||||
|
setSaving(true); setErr(null)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ id: string }>('/api/purchases/supplies', {
|
||||||
|
supplierId: cp.id,
|
||||||
|
storeId, currencyId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
notes: 'Приёмка сканированием',
|
||||||
|
lines: rows.map((r, i) => ({
|
||||||
|
productId: r.productId,
|
||||||
|
quantity: r.qty,
|
||||||
|
unitPrice: r.price,
|
||||||
|
sortOrder: i,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
try { localStorage.removeItem(LS_ROWS) } catch { /* ignored */ }
|
||||||
|
setRows([])
|
||||||
|
nav(`/purchases/supplies/${data.id}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Не удалось создать приёмку')
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-3 pb-28 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => nav(-1)} className="p-2 rounded hover:bg-slate-100"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
|
<h1 className="text-xl font-semibold flex-1">Приёмка сканированием</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* supplier/store */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Поставщик</label>
|
||||||
|
{cp ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5">
|
||||||
|
<span className="flex-1 text-sm">{cp.name}</span>
|
||||||
|
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input value={cpQ} onChange={e => setCpQ(e.target.value)}
|
||||||
|
placeholder="Начните вводить название или БИН…"
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
|
||||||
|
{cpResults.map(h => (
|
||||||
|
<button key={h.id} onClick={() => { setCp(h); setCpQ(''); setCpResults([]) }}
|
||||||
|
className="w-full text-left px-2 py-1.5 border-t border-slate-100 hover:bg-sky-50 text-sm">
|
||||||
|
{h.name}{h.bin && <span className="text-xs text-slate-500 ml-2">БИН {h.bin}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Склад</div>
|
||||||
|
<select value={storeId} onChange={e => setStoreId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Валюта</div>
|
||||||
|
<select value={currencyId} onChange={e => setCurrencyId(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm">
|
||||||
|
{currencies.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* scan controls */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
|
||||||
|
<button onClick={() => { setFb(null); setCameraOn(true) }}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-4 text-lg font-medium hover:bg-sky-700">
|
||||||
|
<Camera className="w-6 h-6" /> Сканировать камерой
|
||||||
|
</button>
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="text-slate-500 cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<Keyboard className="w-4 h-4" /> Или аппаратный сканер — ввод штрихкода
|
||||||
|
</summary>
|
||||||
|
<input ref={inputRef} inputMode="numeric"
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
addScan(el.value); el.value = ''
|
||||||
|
}}
|
||||||
|
placeholder="Отсканируйте или введите штрихкод"
|
||||||
|
className="mt-2 w-full rounded-lg border-2 border-slate-300 px-4 py-3 text-lg tabular-nums outline-none focus:border-sky-500"
|
||||||
|
data-testid="supply-scan-input" />
|
||||||
|
</details>
|
||||||
|
{msg && (
|
||||||
|
<div className={`rounded-md px-3 py-2 text-sm ${msg.ok
|
||||||
|
? 'bg-emerald-50 text-emerald-800' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* rows */}
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 text-xs text-slate-600 flex justify-between">
|
||||||
|
<span>Позиций: {rows.length}</span>
|
||||||
|
<span>Сумма: {total.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="px-4 py-10 text-center text-slate-400 text-sm">Ничего ещё не отсканировано</div>
|
||||||
|
)}
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.productId} className="px-3 py-2.5 border-t border-slate-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">{r.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 font-mono">{r.barcode}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setRows(p => p.filter(x => x.productId !== r.productId))}
|
||||||
|
className="p-1 text-slate-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Кол-во</div>
|
||||||
|
<input type="number" inputMode="decimal" value={r.qty} min={0}
|
||||||
|
onChange={e => setRows(p => p.map(x => x.productId === r.productId
|
||||||
|
? { ...x, qty: parseFloat(e.target.value) || 0 } : x))}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<div className="text-xs text-slate-500">Цена</div>
|
||||||
|
<input type="number" inputMode="decimal" value={r.price}
|
||||||
|
onChange={e => setRows(p => p.map(x => x.productId === r.productId
|
||||||
|
? { ...x, price: parseFloat(e.target.value) || 0 } : x))}
|
||||||
|
className="w-full rounded border border-slate-300 px-2 py-1 tabular-nums text-right" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500">Сумма</div>
|
||||||
|
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1 tabular-nums text-right">
|
||||||
|
{(r.qty * r.price).toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>}
|
||||||
|
|
||||||
|
{cameraOn && (
|
||||||
|
<CameraScanner onClose={() => setCameraOn(false)} onCode={addScan} feedback={fb}
|
||||||
|
counter={`позиций ${rows.length}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<button onClick={save} disabled={saving || !cp || !storeId}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 text-white py-3 font-medium hover:bg-sky-700 disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
|
{saving ? 'Создаём…' : `Создать черновик (${rows.length} поз.)`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue