feat(s31): web — список накладных + экран проверки + apply → Supply (2/8)

InvoicesPage: карточки загрузок со статусом (бейдж + иконка),
поллинг каждые 3с пока есть pending/parsing (останавливается на
needs_review/applied/failed). Две кнопки загрузки:
- 'Снять камерой' (<input capture="environment" accept="image/*">)
- 'Загрузить файл' (image/* + pdf, multi-select)
Mobile-first: кнопки большие, grid 2-col; на desktop растёт.

InvoiceReviewPage: карточками-строками (телефон читается, desktop
масштабируется). Редактируемые поля qty/price, авто-сумма.
- Шапка: supplier, №, дата, сумма (editable). Предупреждение если
  сумма строк отличается от «Итого» больше чем на 1.
- Общий сканер (input Enter) → в первую несопоставленную строку.
- Для несопоставленной строки — 'Найти товар' (debounced search
  по /api/catalog/products?q=) с ссылкой 'Создать товар' если
  ничего не нашлось.
- Save → PUT /lines (пишет alias'ы автоматически).
- Apply → ApplyDialog: выбор поставщика (search с БИН),
  склад (default main), валюта (default KZT). POST /apply →
  редирект в /purchases/supplies/:id.

Routes добавлены в App.tsx: /purchases/invoices и /purchases/
invoices/:id.

tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 21:33:02 +05:00
parent 11b43aefe5
commit 693832f51c
4 changed files with 649 additions and 1 deletions

View file

@ -18,7 +18,7 @@ Storekeeper.
промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально
или через ssh на RemoteHost. Статусы: `queued → extracting → или через ssh на RemoteHost. Статусы: `queued → extracting →
review | failed`. Платные API-ключи не заводим. review | failed`. Платные API-ключи не заводим.
- [ ] 2. **Экран проверки накладной.** Редактируемые строки, помощь в - [x] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
сопоставлении с товаром (barcode → saved-mapping → ручной), создание сопоставлении с товаром (barcode → saved-mapping → ручной), создание
товара прямо из строки, проверка «итого», предложение создать товара прямо из строки, проверка «итого», предложение создать
контрагента. Коммит → наш Supply (Draft). контрагента. Коммит → наш Supply (Draft).

View file

@ -18,6 +18,8 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { StockPage } from '@/pages/StockPage' import { StockPage } from '@/pages/StockPage'
import { SuppliesPage } from '@/pages/SuppliesPage' import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage' import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { InvoicesPage } from '@/pages/InvoicesPage'
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
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'
@ -167,6 +169,9 @@ 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: приёмка по фото накладной. */}
<Route path="/purchases/invoices" element={<InvoicesPage />} />
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
<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)} />

View file

@ -0,0 +1,470 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '@/lib/api'
import { useQuery, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Save, Check, AlertTriangle, Search, Trash2, Barcode as BarcodeIcon } from 'lucide-react'
interface InvoiceLine {
barcode?: string | null
supplierCode?: string | null
name: string
quantity: number
unit?: string | null
price: number
sum?: number | null
vat?: number | null
productId?: string | null
productName?: string | null
matchedBy: string // barcode | alias | manual | none
matchScore: number
}
interface InvoiceDetail {
id: string
fileName: string
status: string
uploadedAt: string
supplierName: string | null
invoiceNumber: string | null
invoiceDate: string | null
totalSum: number | null
linesTotal: number
linesMatched: number
supplyId: string | null
error: string | null
lines: InvoiceLine[]
}
interface ProductSearchHit { id: string; name: string; article: string | null }
interface CounterpartyHit { id: string; name: string; bin: string | null }
interface Store { id: string; name: string; isMain: boolean }
interface Currency { id: string; code: string }
/// Экран проверки накладной. Карточками — чтобы телефон читался; на
/// desktop раскладывается в 2 колонки.
export function InvoiceReviewPage() {
const { id } = useParams<{ id: string }>()
const nav = useNavigate()
const [lines, setLines] = useState<InvoiceLine[]>([])
const [header, setHeader] = useState({ supplierName: '', invoiceNumber: '', invoiceDate: '', totalSum: '' })
const [barcodeInput, setBarcodeInput] = useState('')
const [searchHit, setSearchHit] = useState<{ row: number; q: string } | null>(null)
const [savedMsg, setSavedMsg] = useState<string | null>(null)
const [applyOpen, setApplyOpen] = useState(false)
const detail = useQuery({
queryKey: ['invoice', id],
queryFn: () => api.get<InvoiceDetail>(`/api/purchases/invoices/${id}`).then(r => r.data),
enabled: !!id,
})
useEffect(() => {
const d = detail.data
if (!d) return
setLines(d.lines)
setHeader({
supplierName: d.supplierName ?? '',
invoiceNumber: d.invoiceNumber ?? '',
invoiceDate: d.invoiceDate ? d.invoiceDate.slice(0, 10) : '',
totalSum: d.totalSum != null ? String(d.totalSum) : '',
})
}, [detail.data])
const sumByLines = useMemo(() => lines.reduce((s, l) => s + (l.quantity * l.price || 0), 0), [lines])
const unmatched = useMemo(() => lines.filter(l => !l.productId).length, [lines])
const totalMismatch = useMemo(() => {
const t = parseFloat(header.totalSum || '0')
return t > 0 && Math.abs(sumByLines - t) > 1
}, [sumByLines, header.totalSum])
const save = useMutation({
mutationFn: async () => {
await api.put(`/api/purchases/invoices/${id}/lines`, {
lines, supplierName: header.supplierName, invoiceNumber: header.invoiceNumber,
invoiceDate: header.invoiceDate ? new Date(header.invoiceDate).toISOString() : null,
totalSum: header.totalSum ? parseFloat(header.totalSum) : null,
})
},
onSuccess: () => { setSavedMsg('Сохранено'); setTimeout(() => setSavedMsg(null), 2000) },
onError: (e) => setSavedMsg(e instanceof Error ? e.message : 'Ошибка'),
})
// scan в общий input: ищем товар, матчим в первую НЕсопоставленную подходящую строку
const scanToAny = async (code: string) => {
const clean = code.replace(/\D/g, '')
if (clean.length < 6) return
try {
const { data } = await api.get<{ productId: string; name: string; barcode: string }>(
`/api/purchases/invoices/product-by-barcode?barcode=${clean}`)
// Применяем в первую несопоставленную строку, иначе создаём новую.
setLines(prev => {
const idx = prev.findIndex(l => !l.productId)
if (idx < 0) {
return [...prev, { name: data.name, barcode: clean, quantity: 1, price: 0,
matchedBy: 'manual', matchScore: 1, productId: data.productId, productName: data.name } as InvoiceLine]
}
const next = [...prev]
next[idx] = { ...next[idx], productId: data.productId, productName: data.name,
barcode: next[idx].barcode || clean, matchedBy: 'manual' }
return next
})
} catch { /* не нашли — ignored */ }
setBarcodeInput('')
}
if (!detail.data) return <div className="p-4 text-slate-500">Загрузка…</div>
const d = detail.data
return (
<div className="max-w-5xl mx-auto p-3 pb-28 space-y-3" data-testid="invoice-review">
<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 truncate">{d.supplierName || d.fileName}</h1>
<span className="text-xs text-slate-500">{d.status}</span>
</div>
{d.supplyId && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 text-emerald-800 px-3 py-2 text-sm">
Приёмка уже создана. <a href={`/purchases/supplies/${d.supplyId}`} className="underline">Открыть</a>
</div>
)}
{d.status === 'failed' && d.error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
Ошибка распознавания: {d.error}
</div>
)}
{/* шапка */}
<div className="bg-white rounded-lg border border-slate-200 p-3 space-y-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm col-span-2"
placeholder="Поставщик" value={header.supplierName}
onChange={e => setHeader({ ...header, supplierName: e.target.value })} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm"
placeholder="№" value={header.invoiceNumber}
onChange={e => setHeader({ ...header, invoiceNumber: e.target.value })} />
<input className="rounded border border-slate-300 px-2 py-1.5 text-sm" type="date"
value={header.invoiceDate}
onChange={e => setHeader({ ...header, invoiceDate: e.target.value })} />
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Итого в накладной</span>
<input className="w-28 text-right tabular-nums rounded border border-slate-300 px-2 py-1 text-sm"
inputMode="decimal" placeholder="—" value={header.totalSum}
onChange={e => setHeader({ ...header, totalSum: e.target.value })} />
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Сумма по строкам</span>
<span className="tabular-nums font-semibold">{sumByLines.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}</span>
</div>
{totalMismatch && (
<div className="flex items-start gap-2 text-amber-800 bg-amber-50 border border-amber-200 rounded px-2 py-1.5 text-xs">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
Сумма строк отличается от «Итого» больше чем на 1. Проверьте строки.
</div>
)}
</div>
{/* сканер в общее поле */}
<div className="bg-white rounded-lg border border-slate-200 p-3">
<label className="block">
<span className="text-xs text-slate-500 flex items-center gap-1 mb-1">
<BarcodeIcon className="w-3.5 h-3.5" /> Скан штрихкода → в первую несопоставленную строку
</span>
<input
inputMode="numeric"
value={barcodeInput}
onChange={e => setBarcodeInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); scanToAny((e.target as HTMLInputElement).value) } }}
placeholder="Отсканируйте или введите штрихкод"
className="w-full rounded border border-slate-300 px-2 py-2 text-base tabular-nums"
data-testid="invoice-scan-input"
/>
</label>
</div>
{unmatched > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2 text-sm">
Не сопоставлено строк: <strong>{unmatched}</strong> из {lines.length}. Карточки подсвечены оранжевым.
</div>
)}
{/* строки */}
<div className="space-y-2">
{lines.map((l, i) => (
<InvoiceLineCard
key={i} line={l}
onChange={(patched) => setLines(prev => prev.map((x, j) => j === i ? patched : x))}
onRemove={() => setLines(prev => prev.filter((_, j) => j !== i))}
onSearch={(q) => setSearchHit({ row: i, q })}
/>
))}
</div>
{/* bottom */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 p-3">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 py-3 font-medium hover:bg-slate-50 disabled:opacity-50">
<Save className="w-4 h-4" /> {savedMsg ?? 'Сохранить правки'}
</button>
<button onClick={() => setApplyOpen(true)} disabled={d.status === 'applied' || lines.filter(l => l.productId).length === 0}
className="flex-[1.2] 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">
<Check className="w-4 h-4" /> Создать приёмку
</button>
</div>
</div>
{searchHit && (
<ProductSearchDialog q={searchHit.q} onClose={() => setSearchHit(null)}
onPick={(h) => {
setLines(prev => prev.map((x, j) => j === searchHit.row
? { ...x, productId: h.id, productName: h.name, matchedBy: 'manual' } : x))
setSearchHit(null)
}} />
)}
{applyOpen && (
<ApplyDialog invoiceId={id!} supplierName={header.supplierName}
onClose={() => setApplyOpen(false)}
onDone={(supplyId) => { setApplyOpen(false); nav(`/purchases/supplies/${supplyId}`) }} />
)}
</div>
)
}
/// Карточка строки. Оранжевый бордер если не сопоставлена.
function InvoiceLineCard({ line, onChange, onRemove, onSearch }: {
line: InvoiceLine
onChange: (l: InvoiceLine) => void
onRemove: () => void
onSearch: (q: string) => void
}) {
const bad = !line.productId
const sum = line.quantity * line.price
return (
<div className={`bg-white rounded-lg border p-3 ${bad ? 'border-amber-300' : 'border-slate-200'}`}
data-testid={bad ? 'invoice-line-unmatched' : 'invoice-line-ok'}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm text-slate-500 truncate">
{line.supplierCode && <span className="font-mono bg-slate-100 px-1 rounded">{line.supplierCode}</span>}
{line.barcode && <span className="ml-1 font-mono text-xs">{line.barcode}</span>}
</div>
<div className="font-medium text-slate-900 truncate">{line.name}</div>
{line.productId
? <div className="text-xs text-emerald-700 truncate">→ {line.productName}
<span className="ml-1 text-slate-400">({line.matchedBy})</span>
</div>
: <div className="text-xs text-amber-700">Товар не сопоставлен — найдите или создайте</div>}
</div>
<button onClick={onRemove} className="p-1 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<label>
<div className="text-xs text-slate-500">Кол-во</div>
<input type="number" inputMode="decimal" value={line.quantity}
onChange={e => onChange({ ...line, quantity: parseFloat(e.target.value) || 0 })}
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
</label>
<label>
<div className="text-xs text-slate-500">Цена</div>
<input type="number" inputMode="decimal" value={line.price}
onChange={e => onChange({ ...line, price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border border-slate-300 px-2 py-1.5 tabular-nums text-right" />
</label>
<label>
<div className="text-xs text-slate-500">Сумма</div>
<div className="w-full rounded border border-slate-100 bg-slate-50 px-2 py-1.5 tabular-nums text-right">
{sum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</div>
</label>
</div>
<div className="mt-2 flex gap-2">
<button onClick={() => onSearch(line.name)}
className="flex-1 inline-flex items-center justify-center gap-1 rounded border border-slate-300 px-2 py-1.5 text-xs hover:bg-slate-50">
<Search className="w-3.5 h-3.5" /> Найти товар
</button>
</div>
</div>
)
}
/// Диалог поиска товара (debounced). При выборе — колбэк наверх.
function ProductSearchDialog({ q, onClose, onPick }: {
q: string; onClose: () => void; onPick: (h: ProductSearchHit) => void
}) {
const [query, setQuery] = useState(q)
const search = useQuery({
queryKey: ['product-search', query],
queryFn: async () => {
if (query.trim().length < 2) return []
// Используем общий products endpoint с q=
const { data } = await api.get<{ items: ProductSearchHit[] }>(`/api/catalog/products?q=${encodeURIComponent(query)}&pageSize=20`)
return data.items ?? []
},
enabled: query.trim().length >= 2,
})
return (
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
<div className="bg-white rounded-xl w-full max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-3 border-b border-slate-200 flex items-center gap-2">
<input autoFocus value={query} onChange={e => setQuery(e.target.value)}
placeholder="Название товара…"
className="flex-1 rounded border border-slate-300 px-2 py-2 text-sm" />
<button onClick={onClose} className="px-2 py-1 text-sm text-slate-500 hover:text-slate-800">Отмена</button>
</div>
<div className="flex-1 overflow-y-auto">
{search.data?.map(h => (
<button key={h.id} onClick={() => onPick(h)}
className="w-full text-left px-3 py-2 border-b border-slate-100 hover:bg-sky-50">
<div className="text-sm font-medium">{h.name}</div>
{h.article && <div className="text-xs text-slate-500">{h.article}</div>}
</button>
))}
{search.data && search.data.length === 0 && query.length >= 2 && (
<div className="p-4 text-sm text-slate-500 text-center">
Товар не найден.
<br />
<a href={`/products/new?name=${encodeURIComponent(query)}`} className="text-sky-600 underline">
Создать товар «{query}»
</a>
</div>
)}
</div>
</div>
</div>
)
}
/// Диалог «Создать приёмку»: выбор поставщика (поиск по БИН/имени),
/// склад, валюта. Создаём Draft, редиректим в SupplyEdit.
function ApplyDialog({ invoiceId, supplierName, onClose, onDone }: {
invoiceId: string; supplierName: string
onClose: () => void; onDone: (supplyId: string) => void
}) {
const [cp, setCp] = useState<CounterpartyHit | null>(null)
const [cpQ, setCpQ] = useState(supplierName)
const [storeId, setStoreId] = useState('')
const [currencyId, setCurrencyId] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const cpSearch = useQuery({
queryKey: ['cp-search', cpQ],
queryFn: async () => {
if (!cpQ.trim()) return []
const { data } = await api.get<CounterpartyHit[]>(
`/api/purchases/invoices/counterparty-search?q=${encodeURIComponent(cpQ)}`)
return data
},
enabled: !!cpQ.trim(),
})
const stores = useQuery({
queryKey: ['stores-list'],
queryFn: async () => {
const { data } = await api.get<{ items: Store[] }>('/api/catalog/stores?pageSize=100')
return data.items ?? []
},
})
const currencies = useQuery({
queryKey: ['currencies-list'],
queryFn: async () => {
const { data } = await api.get<Currency[]>('/api/catalog/currencies?pageSize=20')
return Array.isArray(data) ? data : []
},
})
useEffect(() => {
if (!storeId && stores.data?.length) setStoreId(stores.data.find(s => s.isMain)?.id ?? stores.data[0].id)
}, [stores.data, storeId])
useEffect(() => {
if (!currencyId && currencies.data?.length) {
const kzt = currencies.data.find(c => c.code === 'KZT')
setCurrencyId(kzt?.id ?? currencies.data[0].id)
}
}, [currencies.data, currencyId])
const create = async () => {
if (!cp) { setErr('Выберите поставщика'); return }
if (!storeId || !currencyId) { setErr('Выберите склад и валюту'); return }
setBusy(true); setErr(null)
try {
const { data } = await api.post<{ supplyId: string }>(`/api/purchases/invoices/${invoiceId}/apply`, {
supplierId: cp.id, storeId, currencyId,
})
onDone(data.supplyId)
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'Не удалось создать')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-40 bg-black/40 flex items-end sm:items-center justify-center p-2">
<div className="bg-white rounded-xl w-full max-w-md p-4 space-y-3">
<h3 className="font-semibold">Создать приёмку</h3>
<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}{cp.bin && <span className="text-xs text-slate-500 ml-2">БИН {cp.bin}</span>}</span>
<button onClick={() => setCp(null)} className="text-xs text-slate-500 underline">сменить</button>
</div>
) : (
<>
<input autoFocus value={cpQ} onChange={e => setCpQ(e.target.value)}
placeholder="Начните вводить название или БИН…"
className="w-full rounded border border-slate-300 px-2 py-1.5 text-sm" />
{cpSearch.data?.map(h => (
<button key={h.id} onClick={() => setCp(h)}
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>
))}
{cpSearch.data && cpSearch.data.length === 0 && cpQ.length > 0 && (
<div className="pt-1 text-xs text-slate-500">
Не найден.{' '}
<a href={`/counterparties/new?name=${encodeURIComponent(cpQ)}&type=supplier`}
className="text-sky-600 underline">Создать «{cpQ}»</a>
</div>
)}
</>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-slate-500">Склад</span>
<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.data?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs text-slate-500">Валюта</span>
<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.data?.map(c => <option key={c.id} value={c.id}>{c.code}</option>)}
</select>
</label>
</div>
{err && <div className="text-xs text-red-600">{err}</div>}
<div className="flex gap-2 pt-2">
<button onClick={onClose} className="flex-1 rounded border border-slate-300 py-2 text-sm">Отмена</button>
<button onClick={create} disabled={busy || !cp}
className="flex-[1.2] rounded bg-sky-600 text-white py-2 text-sm disabled:opacity-50">
{busy ? 'Создаём…' : 'Создать черновик'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,173 @@
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Upload, Camera, FileText, Loader2, AlertTriangle, Check, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
/// Жизненный цикл: pending → parsing → needs_review → applied | failed.
/// Статус-бейдж + подсказка внизу карточки.
interface InvoiceListRow {
id: string
fileName: string
status: string
uploadedAt: string
supplierName: string | null
invoiceNumber: string | null
invoiceDate: string | null
totalSum: number | null
linesTotal: number
linesMatched: number
supplyId: string | null
error: string | null
}
const STATUS: Record<string, { label: string; cls: string; icon: typeof Clock }> = {
pending: { label: 'В очереди', cls: 'bg-slate-100 text-slate-700', icon: Clock },
parsing: { label: 'Распознаётся', cls: 'bg-sky-100 text-sky-700', icon: Loader2 },
needs_review: { label: 'На проверке', cls: 'bg-amber-100 text-amber-800', icon: FileText },
applied: { label: 'Приёмка создана', cls: 'bg-emerald-100 text-emerald-800', icon: Check },
failed: { label: 'Ошибка', cls: 'bg-red-100 text-red-700', icon: AlertTriangle },
}
export function InvoicesPage() {
const nav = useNavigate()
const qc = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Polling каждые 3 с, пока есть pending/parsing — чтобы юзер видел прогресс
// без ручного F5. Останавливается, когда все статусы терминальные.
const list = useQuery({
queryKey: ['invoices-list'],
queryFn: () => api.get<InvoiceListRow[]>('/api/purchases/invoices?take=50').then(r => r.data),
refetchInterval: (q) => {
const rows = q.state.data as InvoiceListRow[] | undefined
const active = rows?.some(r => r.status === 'pending' || r.status === 'parsing')
return active ? 3000 : false
},
})
const upload = useMutation({
mutationFn: async (files: FileList) => {
const fd = new FormData()
for (const f of Array.from(files)) fd.append('files', f)
await api.post('/api/purchases/invoices/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => { qc.invalidateQueries({ queryKey: ['invoices-list'] }); setErr(null) },
onError: (e: unknown) => setErr(e instanceof Error ? e.message : 'Не удалось загрузить'),
})
const onFiles = async (f: FileList | null) => {
if (!f || f.length === 0) return
setUploading(true)
try { await upload.mutateAsync(f) }
finally { setUploading(false) }
}
return (
<div className="max-w-5xl mx-auto p-4 space-y-4 pb-24">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Накладные</h1>
<p className="text-sm text-slate-500 mt-1">
Загрузите фото или PDF — система распознает строки и создаст приёмку.
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-[auto_auto_1fr]">
<button
onClick={() => cameraRef.current?.click()}
disabled={uploading}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 hover:bg-sky-700 text-white font-medium py-3 px-4 disabled:opacity-60"
data-testid="invoice-upload-camera"
>
<Camera className="w-5 h-5" /> Снять камерой
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 font-medium py-3 px-4 disabled:opacity-60"
data-testid="invoice-upload-file"
>
<Upload className="w-5 h-5" /> Загрузить файл
</button>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} />
<input ref={fileRef} type="file" accept="image/*,application/pdf" multiple
className="hidden" onChange={(e) => onFiles(e.target.files)} />
</div>
{err && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{err}</div>
)}
{uploading && (
<div className="rounded-md border border-sky-200 bg-sky-50 text-sky-700 px-3 py-2 text-sm flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Загрузка…
</div>
)}
<div className="space-y-2">
{list.isLoading && <div className="text-sm text-slate-500">Загрузка…</div>}
{list.data?.length === 0 && !list.isLoading && (
<div className="text-sm text-slate-500 text-center py-16">
Накладных пока нет. Загрузите первое фото — результат появится здесь.
</div>
)}
{list.data?.map(r => {
const S = STATUS[r.status] ?? STATUS.pending
const Icon = S.icon
const clickable = r.status === 'needs_review' || r.status === 'failed' || r.status === 'applied'
return (
<div
key={r.id}
onClick={() => clickable && nav(`/purchases/invoices/${r.id}`)}
className={`bg-white border border-slate-200 rounded-lg p-3 ${clickable ? 'cursor-pointer hover:border-sky-300' : ''}`}
data-testid={`invoice-row-${r.id}`}
>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${S.cls}`}>
<Icon className={`w-3.5 h-3.5 ${r.status === 'parsing' ? 'animate-spin' : ''}`} /> {S.label}
</span>
<span className="text-xs text-slate-400">{new Date(r.uploadedAt).toLocaleString('ru')}</span>
</div>
<div className="mt-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-900 truncate">
{r.supplierName || r.fileName}
</div>
<div className="text-sm text-slate-500 truncate">
{r.invoiceNumber ? `№ ${r.invoiceNumber}` : ''}
{r.invoiceDate ? ` от ${new Date(r.invoiceDate).toLocaleDateString('ru')}` : ''}
{r.supplierName && r.fileName ? ` · ${r.fileName}` : ''}
</div>
{r.linesTotal > 0 && (
<div className="text-xs text-slate-500 mt-1">
{r.linesTotal} строк · сопоставлено {r.linesMatched}
{r.linesMatched < r.linesTotal && <span className="text-amber-600"> · требует внимания</span>}
</div>
)}
{r.error && <div className="text-xs text-red-600 mt-1 line-clamp-2">{r.error}</div>}
</div>
<div className="text-right whitespace-nowrap">
{r.totalSum != null && (
<div className="text-sm font-semibold tabular-nums">
{r.totalSum.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</div>
)}
{r.supplyId && (
<Link to={`/purchases/supplies/${r.supplyId}`} className="text-xs text-sky-600 hover:underline">
Открыть приёмку
</Link>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}