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`), запуск локально
|
промпт из `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).
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
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