feat(инвентаризация): находку можно поправить и убрать до проведения
Механик ошибся в количестве при пересчёте — раньше единственным выходом было бросить инвентаризацию и начать заново. Теперь строку находки можно исправить или удалить, пока пересчёт в черновике. Правка идёт через тот же фасад склада, что и остальные складские действия приложения, — отдельного пути в обход правил остатков не появилось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 variant="outline" size="sm" onClick={() => tap(line.qty_expected)}>
|
||||
Сходится
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setDraft("0"); onSet(0); }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user