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:
nns 2026-10-04 21:36:41 +05:00
parent 693832f51c
commit 05e7fdecef
7 changed files with 491 additions and 4 deletions

View file

@ -22,7 +22,7 @@ Storekeeper.
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
товара прямо из строки, проверка «итого», предложение создать
контрагента. Коммит → наш Supply (Draft).
- [ ] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
- [x] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на
скан, последняя закупочная подставляется, неизвестный штрихкод →
предложить создать товар или отложить.

View file

@ -301,7 +301,9 @@ public async Task<ActionResult<ApplyResult>> Apply(Guid id, [FromBody] ApplyDto
// ── помощники для UI ────────────────────────────────────────────────
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
/// открытия отдельного экрана поиска. Возвращает 404 если нет.</summary>
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
/// подставлял цену автоматически).</summary>
[HttpGet("product-by-barcode")]
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 })
.FirstOrDefaultAsync(ct);
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) — предлагаем

View file

@ -15,6 +15,8 @@
"@microsoft/signalr": "^10.0.0",
"@tanstack/react-query": "^5.99.2",
"@tanstack/react-query-devtools": "^5.99.2",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.23.0",
"axios": "^1.15.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -20,6 +20,12 @@ importers:
'@tanstack/react-query-devtools':
specifier: ^5.99.2
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:
specifier: ^1.15.1
version: 1.15.1
@ -679,6 +685,18 @@ packages:
babel-plugin-react-compiler:
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:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@ -1592,6 +1610,10 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -2312,6 +2334,21 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7
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:
dependencies:
event-target-shim: 5.0.1
@ -3119,6 +3156,8 @@ snapshots:
dependencies:
typescript: 6.0.3
ts-custom-error@3.3.1: {}
tslib@2.8.1:
optional: true

View file

@ -20,6 +20,7 @@ import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { InvoicesPage } from '@/pages/InvoicesPage'
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
import { SupplyScanPage } from '@/pages/SupplyScanPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
@ -169,9 +170,10 @@ export default function App() {
<Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
{/* Sprint 31: приёмка по фото накладной. */}
{/* Sprint 31: приёмка по фото накладной + сканированием. */}
<Route path="/purchases/invoices" element={<InvoicesPage />} />
<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/new" element={lz(EnterEditPage)} />
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />

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

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