fix(supply-quick-add): sticky input at viewport bottom + auto-scroll on add

Quick-add bar теперь не sticky-внутри-Section, а отдельный flex-sibling
формы — всегда прибит к нижнему краю viewport независимо от высоты
содержимого и overflow-hidden у Section'а:

  <form flex flex-col h-full>
    <topbar />
    <body flex-1 overflow-auto>     ← скроллится
    <quick-add bar flex-shrink-0>   ← всегда виден
  </form>

После каждого добавления строки скролл-контейнер тела документа
автоскроллится к низу (smooth, через requestAnimationFrame чтобы
дождаться рендера новой строки) — новая строка всегда появляется
прямо над input'ом и пользователь видит подтверждение скана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 02:42:24 +05:00
parent b56c499b45
commit 970a9baec3

View file

@ -1,4 +1,4 @@
import { useState, useEffect, type FormEvent, type ReactNode } from 'react' import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
@ -65,6 +65,16 @@ export function SupplyEditPage() {
const [form, setForm] = useState<Form>(emptyForm) const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Скролл-контейнер тела документа: после каждого добавления строки
// автоскроллим к низу, чтобы новая строка и input оказались в зоне видимости.
const scrollBodyRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
const el = scrollBodyRef.current
if (!el) return
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
})
}
const existing = useQuery({ const existing = useQuery({
queryKey: ['/api/purchases/supplies', id], queryKey: ['/api/purchases/supplies', id],
@ -211,6 +221,7 @@ export function SupplyEditPage() {
...form, ...form,
lines: form.lines.map((l, ix) => ix === idx ? { ...l, quantity: l.quantity + 1 } : l), lines: form.lines.map((l, ix) => ix === idx ? { ...l, quantity: l.quantity + 1 } : l),
}) })
scrollToBottom()
return true return true
} }
const defaultRetail = p.prices?.[0]?.amount ?? null const defaultRetail = p.prices?.[0]?.amount ?? null
@ -228,6 +239,7 @@ export function SupplyEditPage() {
retailPriceOverride: null, retailPriceOverride: null,
}], }],
}) })
scrollToBottom()
return false return false
} }
const updateLine = (i: number, patch: Partial<LineRow>) => const updateLine = (i: number, patch: Partial<LineRow>) =>
@ -273,7 +285,7 @@ export function SupplyEditPage() {
</div> </div>
{/* Scrollable body */} {/* Scrollable body */}
<div className="flex-1 overflow-auto"> <div ref={scrollBodyRef} className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5"> <div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
{error && ( {error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
@ -448,23 +460,25 @@ export function SupplyEditPage() {
)} )}
</Section> </Section>
{!isPosted && (
// Sticky-bottom вне Section (overflow-hidden родителя ломает sticky):
// пользователь может подряд сканировать партию штрихкодов — после
// каждого скана строка добавляется в таблицу выше, а input остаётся
// прибит к низу скроллируемого тела документа и принимает следующий ввод.
<div className="sticky bottom-0 -mx-3 sm:-mx-6 px-3 sm:px-6 py-3 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 z-10 shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
<SupplyLineQuickAdd
storeId={form.storeId}
onPick={addOrIncrementLine}
linesCount={form.lines.length}
/>
</div>
)}
</div> </div>
</div> </div>
{/* Quick-add bar flex-sibling формы, всегда у нижнего края viewport.
* Не sticky внутри scroll-body, чтобы overflow родителей и высота
* содержимого не влияли на видимость. После каждого добавления
* строки тело документа автоскроллится к низу. */}
{!isPosted && (
<div className="flex-shrink-0 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 px-3 sm:px-6 py-3 shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
<div className="max-w-6xl mx-auto">
<SupplyLineQuickAdd
storeId={form.storeId}
onPick={addOrIncrementLine}
linesCount={form.lines.length}
/>
</div>
</div>
)}
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} /> <ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
</form> </form>
) )