feat(инвентаризация): находку можно поправить и убрать до проведения

Механик ошибся в количестве при пересчёте — раньше единственным выходом было
бросить инвентаризацию и начать заново. Теперь строку находки можно исправить
или удалить, пока пересчёт в черновике.

Правка идёт через тот же фасад склада, что и остальные складские действия
приложения, — отдельного пути в обход правил остатков не появилось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 06:53:29 +10:00
co-authored by Claude Opus 5
parent 58c9a2ca1e
commit 39176d2694
2 changed files with 453 additions and 47 deletions
@@ -5,8 +5,34 @@
* дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в
* ошибку там, куда ему не надо.
*/
import { HTTPError } from "ky";
import { api } from "./client";
/**
* Текст отказа, который сервер написал для человека.
*
* ky кладёт в Error.message служебное «Request failed with status code 409», а
* понятную причину бэкенд пишет в detail («Удалить находку нельзя: деталь
* выдали после пересчёта»). Механику у стеллажа нужна именно она: по номеру
* статуса он не поймёт, что делать дальше, и повторит то же действие.
*
* Запасной текст обязателен: тело читается один раз (при повторном вызове поток
* уже пуст), у прокси-ошибок его может не быть вовсе, а валидационный 422 от
* pydantic кладёт в detail список объектов — человеку он ничего не говорит.
* Не-HTTP ошибка (связь отвалилась, таймаут) тоже уходит в запасной: у ky там
* английское «Failed to fetch».
*/
export async function apiErrorText(e: unknown, fallback: string): Promise<string> {
if (!(e instanceof HTTPError)) return fallback;
try {
const { detail } = (await e.response.json()) as { detail?: unknown };
if (typeof detail === "string" && detail.trim()) return detail.trim();
} catch {
// тело не JSON или уже прочитано — остаётся запасной текст
}
return fallback;
}
// ── Справочники ────────────────────────────────────────────────────────────
export interface WhRef {
@@ -278,6 +304,45 @@ export async function addFind(
return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json<StocktakeLine>();
}
/**
* Поправить находку в черновике.
*
* Отдельная ручка, а не countLine: сервер пишет в аудит «было → стало» и не
* даёт свести количество к нулю — от находки отказываются удалением строки,
* иначе она остаётся в документе пустышкой «числилось 0 — столько и нашли».
* Поля опциональны: шлём только то, что правим.
*/
export async function patchFind(
stocktakeId: number,
lineId: number,
body: {
qty?: number;
unit_price?: number;
condition?: "new" | "used";
seasons_left?: number | null;
comment?: string | null;
reason_code?: string | null;
},
): Promise<StocktakeLine> {
return api
.patch(`wh/stocktakes/${stocktakeId}/finds/${lineId}`, { json: body })
.json<StocktakeLine>();
}
/**
* Убрать строку из черновика. Сервер разрешает только находку: обычную строку
* не заводил человек — она означает «эту позицию надо обойти», и её удаление
* молча вычеркнуло бы позицию из пересчёта.
*/
export async function deleteStocktakeLine(
stocktakeId: number,
lineId: number,
): Promise<{ ok: boolean }> {
return api
.delete(`wh/stocktakes/${stocktakeId}/lines/${lineId}`)
.json<{ ok: boolean }>();
}
export async function applyStocktake(id: number): Promise<StocktakeDetail> {
return api.post(`wh/stocktakes/${id}/apply`).json<StocktakeDetail>();
}
@@ -9,26 +9,44 @@
* Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не
* нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой.
*
* Находки живут ОТДЕЛЬНЫМ блоком, а не строками рабочего списка. Причина не
* косметическая: у находки количество проставлено с рождения, поэтому фильтр
* «только непосчитанные» прятал её сразу после добавления — со стороны это
* читалось как «нажал, и оно пропало». Вынести их из-под фильтра исключением
* значило бы держать в одном списке две разные сущности: обход («что надо
* проверить», и его-то и считает прогресс) и находки («что появилось сверх
* списка»). Отдельный блок сохраняет смысл фильтра и прогресса и ставит
* находку прямо под кнопкой, которой её добавили.
*
* Строка, в поле которой стоит курсор, из списка не исчезает, даже если её
* значение уже ушло на сервер. Иначе фильтр выдёргивает строку из-под пальца
* посреди набора — и в документе остаётся то, что успели напечатать. Отсюда
* focusedId на этом уровне: сам фильтр живёт здесь, а знает про фокус строка.
*
* Проведённая сессия сюда не доходит: она уже не рабочий список, а документ,
* и показывается экраном итогов (StocktakeResult). Адрес один на одну сущность
* — id до и после проведения тот же, поэтому разводим не роутом, а статусом:
* так работает и ссылка из списка, и переход сразу после «Провести».
*/
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Loader2, Plus, RefreshCw } from "lucide-react";
import { ChevronLeft, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import PartsPickerModal from "@/components/repairs/PartsPickerModal";
import QtyStepper from "@/components/repairs/QtyStepper";
import { fmtMoney } from "@/lib/repairsFeed";
import {
addFind,
apiErrorText,
applyStocktake,
countLine,
deleteStocktake,
deleteStocktakeLine,
getStocktake,
patchFind,
type StocktakeDetail,
type StocktakeLine,
} from "@/api/warehouse";
@@ -44,6 +62,11 @@ export default function StocktakeSessionPage() {
const [onlyPending, setOnlyPending] = useState(true);
const [busy, setBusy] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
// Выбранная в пикере деталь ждёт количество: сколько нашли, спрашиваем ДО
// отправки — иначе коробка из восьми уходит в учёт одной штукой.
const [findDraft, setFindDraft] = useState<PartCatalogItem | null>(null);
// Строка, в поле которой сейчас курсор: её фильтр не трогает (см. шапку).
const [focusedId, setFocusedId] = useState<number | null>(null);
const query = useQuery({
queryKey: ["wh-stocktake", stId],
@@ -56,13 +79,28 @@ export default function StocktakeSessionPage() {
const st = data ?? query.data ?? null;
const isDraft = st?.status === "draft";
const visible = useMemo(() => {
const lines = st?.lines ?? [];
return onlyPending ? lines.filter((l) => l.qty_counted == null) : lines;
}, [st, onlyPending]);
// Обход и находки — разные списки (см. шапку файла). В черновике находка
// опознаётся по batch_id = null; после проведения признак затирается, но
// проведённая сессия сюда не доходит.
const { walk, finds } = useMemo(() => {
const walk: StocktakeLine[] = [];
const finds: StocktakeLine[] = [];
for (const l of st?.lines ?? []) (l.batch_id == null ? finds : walk).push(l);
return { walk, finds };
}, [st]);
const counted = (st?.lines ?? []).filter((l) => l.qty_counted != null).length;
const totalLines = st?.lines.length ?? 0;
// Посчитанная строка уходит из списка — но не та, в которой сейчас курсор:
// её значение могло уйти на сервер из-под пальца (погас экран), и исчезни
// она сейчас — механик дописывал бы вторую цифру в никуда.
const visible = useMemo(
() => (onlyPending ? walk.filter((l) => l.qty_counted == null || l.id === focusedId) : walk),
[walk, onlyPending, focusedId],
);
// Прогресс — про обход: находки в него не входят, иначе доля обойдённого
// растёт от того, что человек нашёл лишнего (так же считает экран итогов).
const counted = walk.filter((l) => l.qty_counted != null).length;
const totalLines = walk.length;
const totals = useMemo(() => {
let shortQty = 0, shortAmt = 0, surQty = 0, surAmt = 0;
@@ -87,25 +125,59 @@ export default function StocktakeSessionPage() {
return { ...base, lines: base.lines.map((l) => (l.id === updated.id ? updated : l)) };
});
const setCount = async (line: StocktakeLine, value: number | null) => {
const dropLine = (lineId: number) =>
setData((cur) => {
const base = cur ?? query.data ?? null;
if (!base) return cur;
return { ...base, lines: base.lines.filter((l) => l.id !== lineId) };
});
// Сохранилось или нет — строка обязана знать: после отказа её поле должно
// вернуться к тому, что реально лежит в документе.
const setCount = async (line: StocktakeLine, value: number | null): Promise<boolean> => {
try {
applyLine(await countLine(stId, line.id, { qty_counted: value }));
return true;
} catch (e) {
toast.error((e as Error).message || "Не удалось сохранить");
toast.error(await apiErrorText(e, "Не удалось сохранить"));
return false;
}
};
const onFind = async (item: PartCatalogItem) => {
setPickerOpen(false);
// Количество находки правим её собственной ручкой: она пишет в аудит
// «было → стало» — исправление должно быть видно, а не выглядеть так, будто
// находку сразу такой и вносили.
const setFindQty = async (line: StocktakeLine, qty: number): Promise<boolean> => {
try {
const line = await addFind(stId, { part_id: item.id, qty: 1 });
applyLine(await patchFind(stId, line.id, { qty }));
return true;
} catch (e) {
toast.error(await apiErrorText(e, "Не удалось сохранить количество"));
return false;
}
};
const removeFind = async (line: StocktakeLine) => {
if (!window.confirm(`Убрать находку «${line.part_name}» из пересчёта?`)) return;
try {
await deleteStocktakeLine(stId, line.id);
dropLine(line.id);
} catch (e) {
toast.error(await apiErrorText(e, "Не удалось убрать находку"));
}
};
const onFind = async (item: PartCatalogItem, qty: number) => {
setFindDraft(null);
try {
const line = await addFind(stId, { part_id: item.id, qty });
setData((cur) => {
const base = cur ?? query.data ?? null;
return base ? { ...base, lines: [...base.lines, line] } : cur;
});
toast.success(`Находка добавлена: ${item.name}`);
toast.success(`Находка добавлена: ${item.name}, ${qty} шт`);
} catch (e) {
toast.error((e as Error).message || "Не удалось добавить находку");
toast.error(await apiErrorText(e, "Не удалось добавить находку"));
}
};
@@ -121,7 +193,7 @@ export default function StocktakeSessionPage() {
setData(await applyStocktake(stId));
toast.success("Инвентаризация проведена");
} catch (e) {
toast.error((e as Error).message || "Не удалось провести");
toast.error(await apiErrorText(e, "Не удалось провести"));
} finally {
setBusy(false);
}
@@ -134,7 +206,7 @@ export default function StocktakeSessionPage() {
await deleteStocktake(stId);
navigate("/wh/stocktake", { replace: true });
} catch (e) {
toast.error((e as Error).message || "Не удалось удалить");
toast.error(await apiErrorText(e, "Не удалось удалить"));
setBusy(false);
}
};
@@ -160,7 +232,10 @@ export default function StocktakeSessionPage() {
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>посчитано {counted} из {totalLines}</span>
<span>
посчитано {counted} из {totalLines}
{finds.length > 0 && ` · находок ${finds.length}`}
</span>
{isDraft && (
<button
type="button"
@@ -206,6 +281,27 @@ export default function StocktakeSessionPage() {
</Button>
)}
{/* Находки — сразу под кнопкой, которой их добавляют, и вне фильтра
обхода (см. шапку файла). */}
{st && isDraft && finds.length > 0 && (
<section className="space-y-2 pt-1">
<div className="flex items-baseline justify-between gap-2 px-1">
<h2 className="text-sm font-semibold">Находки · {finds.length}</h2>
<span className="text-xs text-muted-foreground">в учёте их не числилось</span>
</div>
<ul className="space-y-2">
{finds.map((l) => (
<FindRow
key={l.id}
line={l}
onQty={(qty) => setFindQty(l, qty)}
onRemove={() => void removeFind(l)}
/>
))}
</ul>
</section>
)}
{st && visible.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
{onlyPending && totalLines > 0
@@ -216,7 +312,13 @@ export default function StocktakeSessionPage() {
<ul className="space-y-2">
{visible.map((l) => (
<LineRow key={l.id} line={l} editable={!!isDraft} onSet={(v) => void setCount(l, v)} />
<LineRow
key={l.id}
line={l}
editable={!!isDraft}
onSet={(v) => setCount(l, v)}
onFocusChange={(f) => setFocusedId((cur) => (f ? l.id : cur === l.id ? null : cur))}
/>
))}
</ul>
@@ -236,13 +338,15 @@ export default function StocktakeSessionPage() {
<span className={totals.shortQty ? "text-destructive font-medium" : "text-muted-foreground"}>
недостача {totals.shortQty} · {fmtMoney(totals.shortAmt)}
</span>
<span className={totals.surQty ? "text-emerald-700 font-medium" : "text-muted-foreground"}>
<span className={totals.surQty ? "font-medium" : "text-muted-foreground"}>
излишек {totals.surQty} · {fmtMoney(totals.surAmt)}
</span>
</div>
</div>
{isDraft && (
<Button onClick={onApply} disabled={busy || counted === 0}>
// Одни находки — тоже повод провести: обход мог не дать ни одной
// посчитанной строки, а найденное уже надо оприходовать.
<Button onClick={onApply} disabled={busy || (counted === 0 && finds.length === 0)}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Провести"}
</Button>
)}
@@ -255,28 +359,202 @@ export default function StocktakeSessionPage() {
<PartsPickerModal
open={pickerOpen}
carId={null}
onPick={(item) => void onFind(item)}
onPick={(item) => { setPickerOpen(false); setFindDraft(item); }}
onClose={() => setPickerOpen(false)}
/>
{findDraft && (
<FindQtyDialog
item={findDraft}
onCancel={() => setFindDraft(null)}
onConfirm={(qty) => void onFind(findDraft, qty)}
/>
)}
</div>
);
}
/**
* Сколько нашли — спрашиваем до отправки, отдельным шагом после выбора детали.
*
* Раньше находка уходила жёсткой единицей: механик нашёл коробку из восьми, в
* учёт попадала одна, и поправить было нечем. Ввод берём тот же, что у деталей
* в ремонте (QtyStepper): −/+ нажимаются в перчатках, а пустое поле остаётся
* пустым, пока набирают, — иначе «8» не набрать поверх единицы.
*/
function FindQtyDialog({
item, onCancel, onConfirm,
}: {
item: PartCatalogItem;
onCancel: () => void;
onConfirm: (qty: number) => void;
}) {
const [qty, setQty] = useState<number | "">(1);
const value = qty === "" ? 1 : qty;
return (
<div className="fixed inset-0 z-50 bg-background/95 flex items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border bg-card p-4 space-y-4">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">Нашли на полке</div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground truncate">
{item.article ? `арт. ${item.article}` : "без артикула"}
{item.brand ? ` · ${item.brand}` : ""}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm">Сколько штук</span>
<QtyStepper value={qty} onChange={setQty} />
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">
Отмена
</Button>
<Button onClick={() => onConfirm(value)} className="flex-1">
Добавить
</Button>
</div>
</div>
</div>
);
}
/**
* Отправка набранного количества: один запрос на весь ввод, и ни одного —
* пока в поле стоит курсор.
*
* Здесь была пауза в 700 мс, и она портила склад. Промежуточное значение
* уходило на сервер, строка переставала быть непосчитанной, фильтр «только
* непосчитанные» убирал её из списка прямо посреди набора: механик вводил
* «12», в документе оставалась «1» — и это уходило в списание недостачи.
*
* Поэтому hold: пока с полем работают, набранное только удерживается, а на
* сервер уходит по завершению ввода (sendNow/flush на уходе с поля). Терять
* удержанное нельзя — ровно от этого автосохранение и делалось, — поэтому на
* время удержания висят слушатели «страницу прячут»: гашение экрана и
* сворачивание приложения дают ровно эти события. Размонтирование строки
* (ушли с экрана) отправляет удержанное тем же flush.
*
* schedule (пауза) остался для правок, у которых нет момента «ушёл с поля», —
* это кнопки −/+ у находки на мобильном: они фокус не берут, ждать нечего.
*
* Слушатели вешаем только пока есть несохранённое значение: на пересчёте всего
* склада строк сотни, и постоянный слушатель у каждой — лишний хвост на каждом
* сворачивании.
*/
function useDeferredSave<T>(commit: (value: T) => void | Promise<void>, delayMs = 700) {
const commitRef = useRef(commit);
const pending = useRef<{ value: T } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const unlisten = useRef<(() => void) | null>(null);
useEffect(() => { commitRef.current = commit; });
const stopTimer = useCallback(() => {
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
}, []);
const stopListen = useCallback(() => {
unlisten.current?.();
unlisten.current = null;
}, []);
const flush = useCallback(() => {
const p = pending.current;
pending.current = null;
stopTimer();
stopListen();
if (p) void commitRef.current(p.value);
}, [stopListen, stopTimer]);
/** Запомнить набранное и ждать — с подстраховкой на случай «экран погас». */
const hold = useCallback((value: T) => {
stopTimer();
pending.current = { value };
if (unlisten.current) return;
const onHidden = () => { if (document.visibilityState === "hidden") flush(); };
document.addEventListener("visibilitychange", onHidden);
window.addEventListener("pagehide", flush);
unlisten.current = () => {
document.removeEventListener("visibilitychange", onHidden);
window.removeEventListener("pagehide", flush);
};
}, [flush, stopTimer]);
const schedule = useCallback((value: T) => {
hold(value);
timer.current = setTimeout(flush, delayMs);
}, [delayMs, flush, hold]);
const sendNow = useCallback((value: T) => {
pending.current = { value };
flush();
}, [flush]);
const cancel = useCallback(() => {
pending.current = null;
stopTimer();
stopListen();
}, [stopListen, stopTimer]);
// Размонтирование (ушли с экрана, список перерисовался) — та же потеря.
useEffect(() => flush, [flush]);
return { hold, schedule, sendNow, flush, cancel };
}
/**
* Что набрано в поле: число, null — «ещё не считали» (пустое поле),
* undefined — мусор, отправлять нечего.
*/
function parseQty(raw: string): number | null | undefined {
const s = raw.trim();
if (s === "") return null;
const n = Math.floor(Number(s));
return Number.isFinite(n) && n >= 0 ? n : undefined;
}
function LineRow({
line, editable, onSet,
line, editable, onSet, onFocusChange,
}: {
line: StocktakeLine;
editable: boolean;
onSet: (v: number | null) => void;
onSet: (v: number | null) => Promise<boolean>;
onFocusChange: (focused: boolean) => void;
}) {
const [draft, setDraft] = useState<string>(line.qty_counted?.toString() ?? "");
// Сервер отказал — в документе осталось прежнее, и поле обязано показывать
// его же: иначе на экране висит количество, которого в базе нет, и строка
// выглядит обойдённой.
const save = useDeferredSave<number | null>(async (v) => {
if (!(await onSet(v))) setDraft(line.qty_counted?.toString() ?? "");
});
const tone =
line.resolution === "shortage" ? "border-destructive/40 bg-destructive/5"
: line.resolution === "surplus" ? "border-emerald-500/40 bg-emerald-500/5"
: line.resolution === "surplus" ? "border-border bg-accent/40"
: line.resolution === "match" ? "border-border bg-muted/30"
: "border-border bg-card";
const onType = (raw: string) => {
setDraft(raw);
const v = parseQty(raw);
// Пустое поле посреди набора — ещё не решение «не считали»: стирание
// отправляем только когда человек ушёл с поля.
if (v == null || v === line.qty_counted) save.cancel();
else save.hold(v);
};
const onLeave = () => {
onFocusChange(false);
const v = parseQty(draft);
if (v === undefined || v === line.qty_counted) save.cancel();
else save.sendNow(v);
};
const tap = (v: number) => { setDraft(String(v)); save.sendNow(v); };
return (
<li className={`rounded-lg border p-3 ${tone}`}>
<div className="flex items-start justify-between gap-2">
@@ -284,14 +562,14 @@ function LineRow({
<div className="text-sm font-medium truncate">{line.part_name}</div>
<div className="text-xs text-muted-foreground truncate">
{line.article ? `арт. ${line.article} · ` : ""}
{line.batch_id == null ? "находка" : `числится ${line.qty_expected}`}
числится {line.qty_expected}
{line.place_code ? ` · ${line.place_code}` : ""}
</div>
</div>
{line.delta !== 0 && (
<span
className={`text-sm font-semibold whitespace-nowrap ${
line.delta < 0 ? "text-destructive" : "text-emerald-700"
className={`text-sm font-semibold whitespace-nowrap tabular-nums ${
line.delta < 0 ? "text-destructive" : ""
}`}
>
{line.delta > 0 ? `+${line.delta}` : line.delta}
@@ -307,27 +585,15 @@ function LineRow({
min={0}
placeholder="сколько нашли"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
const v = draft.trim() === "" ? null : Math.max(0, Number(draft));
if (v !== line.qty_counted) onSet(v);
}}
onChange={(e) => onType(e.target.value)}
onFocus={() => onFocusChange(true)}
onBlur={onLeave}
className="flex-1"
/>
{line.batch_id != null && (
<Button
variant="outline"
size="sm"
onClick={() => { setDraft(String(line.qty_expected)); onSet(line.qty_expected); }}
>
Сходится
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => { setDraft("0"); onSet(0); }}
>
<Button variant="outline" size="sm" onClick={() => tap(line.qty_expected)}>
Сходится
</Button>
<Button variant="outline" size="sm" onClick={() => tap(0)}>
Нет
</Button>
</div>
@@ -335,3 +601,78 @@ function LineRow({
</li>
);
}
/**
* Находка в черновике: количество правится, ошибочную строку убирают.
*
* Кнопок «Сходится»/«Нет» здесь нет намеренно. Сходиться находке не с чем — в
* учёте её не числилось, — а «Нет» раньше служил удалением и ставил ноль:
* строка навсегда оставалась в документе сошедшейся пустышкой «числилось 0 —
* столько и нашли» и завышала счётчик совпавших. Ошибочная находка теперь
* удаляется по-настоящему.
*/
function FindRow({
line, onQty, onRemove,
}: {
line: StocktakeLine;
onQty: (qty: number) => Promise<boolean>;
onRemove: () => void;
}) {
const [qty, setQty] = useState<number | "">(line.qty_counted ?? 1);
// Сервер отказал (например, находку уже провели) — поле возвращается к тому,
// что реально лежит в документе, а не остаётся с непринятым числом.
const save = useDeferredSave<number>(async (next) => {
if (!(await onQty(next))) setQty(line.qty_counted ?? 1);
});
// Курсор внутри строки: пока правят, число только удерживается. Сервер пишет
// каждую правку в аудит «было → стало», и промежуточная «1» из «12» осталась
// бы там навсегда.
const editing = useRef(false);
const onChange = (next: number | "") => {
setQty(next);
// "" — промежуточное состояние ввода (см. QtyStepper), отправлять нечего;
// QtyStepper сам вернёт минимум, когда человек уйдёт с поля.
if (next === "" || next < 1 || next === line.qty_counted) save.cancel();
else if (editing.current) save.hold(next);
// Кнопки −/+ на мобильном фокуса не берут: уходить неоткуда, шлём по паузе.
else save.schedule(next);
};
return (
<li className="rounded-lg border border-border bg-accent/40 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{line.part_name}</div>
<div className="text-xs text-muted-foreground truncate">
{line.article ? `арт. ${line.article} · ` : ""}
находка
{line.place_code ? ` · ${line.place_code}` : ""}
</div>
</div>
<span className="text-sm font-semibold whitespace-nowrap tabular-nums">
+{line.qty_counted ?? 0}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
{/* Границы правки — сам счётчик: переход поле ↔ −/+ её не завершает,
а уход из счётчика (в том числе табом на «Убрать») отправляет. */}
<div
onFocus={() => { editing.current = true; }}
onBlur={(e) => {
if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) return;
editing.current = false;
save.flush();
}}
>
<QtyStepper value={qty} onChange={onChange} />
</div>
<Button variant="ghost" size="sm" onClick={onRemove} className="text-destructive">
<Trash2 className="h-4 w-4 mr-1" />
Убрать
</Button>
</div>
</li>
);
}