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>
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|