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:
parent
11b43aefe5
commit
693832f51c
|
|
@ -18,7 +18,7 @@ Storekeeper.
|
|||
промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально
|
||||
или через ssh на RemoteHost. Статусы: `queued → extracting →
|
||||
review | failed`. Платные API-ключи не заводим.
|
||||
- [ ] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
|
||||
- [x] 2. **Экран проверки накладной.** Редактируемые строки, помощь в
|
||||
сопоставлении с товаром (barcode → saved-mapping → ручной), создание
|
||||
товара прямо из строки, проверка «итого», предложение создать
|
||||
контрагента. Коммит → наш Supply (Draft).
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
|
|||
import { StockPage } from '@/pages/StockPage'
|
||||
import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||
import { InvoicesPage } from '@/pages/InvoicesPage'
|
||||
import { InvoiceReviewPage } from '@/pages/InvoiceReviewPage'
|
||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||
|
|
@ -167,6 +169,9 @@ 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: приёмка по фото накладной. */}
|
||||
<Route path="/purchases/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/purchases/invoices/:id" element={<InvoiceReviewPage />} />
|
||||
<Route path="/inventory/enters" element={lz(EntersPage)} />
|
||||
<Route path="/inventory/enters/new" element={lz(EnterEditPage)} />
|
||||
<Route path="/inventory/enters/:id" element={lz(EnterEditPage)} />
|
||||
|
|
|
|||
470
src/food-market.web/src/pages/InvoiceReviewPage.tsx
Normal file
470
src/food-market.web/src/pages/InvoiceReviewPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/food-market.web/src/pages/InvoicesPage.tsx
Normal file
173
src/food-market.web/src/pages/InvoicesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue