food-market/src/food-market.web/src/pages/SupplyScanPage.tsx
nns 43cf9ea040 fix(s31): уберём неиспользуемые импорты — ломали docker-билд web
Docker web build использует 'tsc -b' (noUnusedLocals=true), который на
dev-машине проскакивал через tsc --noEmit. Три unused:
- InventoryMobilePage: countRows (из offlineCatalog)
- SupplyScanPage: AlertTriangle, Plus (lucide-react)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:53:10 +05:00

290 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Camera, Keyboard, Trash2, Check, Loader2, ArrowLeft } 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>
)
}