feat(инвентаризация): находку можно поправить и убрать до проведения
Механик ошибся в количестве при пересчёте — раньше единственным выходом было бросить инвентаризацию и начать заново. Теперь строку находки можно исправить или удалить, пока пересчёт в черновике. Правка идёт через тот же фасад склада, что и остальные складские действия приложения, — отдельного пути в обход правил остатков не появилось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,34 @@
|
|||||||
* дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в
|
* дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в
|
||||||
* ошибку там, куда ему не надо.
|
* ошибку там, куда ему не надо.
|
||||||
*/
|
*/
|
||||||
|
import { HTTPError } from "ky";
|
||||||
import { api } from "./client";
|
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 {
|
export interface WhRef {
|
||||||
@@ -278,6 +304,45 @@ export async function addFind(
|
|||||||
return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json<StocktakeLine>();
|
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> {
|
export async function applyStocktake(id: number): Promise<StocktakeDetail> {
|
||||||
return api.post(`wh/stocktakes/${id}/apply`).json<StocktakeDetail>();
|
return api.post(`wh/stocktakes/${id}/apply`).json<StocktakeDetail>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,44 @@
|
|||||||
* Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не
|
* Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не
|
||||||
* нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой.
|
* нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой.
|
||||||
*
|
*
|
||||||
|
* Находки живут ОТДЕЛЬНЫМ блоком, а не строками рабочего списка. Причина не
|
||||||
|
* косметическая: у находки количество проставлено с рождения, поэтому фильтр
|
||||||
|
* «только непосчитанные» прятал её сразу после добавления — со стороны это
|
||||||
|
* читалось как «нажал, и оно пропало». Вынести их из-под фильтра исключением
|
||||||
|
* значило бы держать в одном списке две разные сущности: обход («что надо
|
||||||
|
* проверить», и его-то и считает прогресс) и находки («что появилось сверх
|
||||||
|
* списка»). Отдельный блок сохраняет смысл фильтра и прогресса и ставит
|
||||||
|
* находку прямо под кнопкой, которой её добавили.
|
||||||
|
*
|
||||||
|
* Строка, в поле которой стоит курсор, из списка не исчезает, даже если её
|
||||||
|
* значение уже ушло на сервер. Иначе фильтр выдёргивает строку из-под пальца
|
||||||
|
* посреди набора — и в документе остаётся то, что успели напечатать. Отсюда
|
||||||
|
* focusedId на этом уровне: сам фильтр живёт здесь, а знает про фокус строка.
|
||||||
|
*
|
||||||
* Проведённая сессия сюда не доходит: она уже не рабочий список, а документ,
|
* Проведённая сессия сюда не доходит: она уже не рабочий список, а документ,
|
||||||
* и показывается экраном итогов (StocktakeResult). Адрес один на одну сущность
|
* и показывается экраном итогов (StocktakeResult). Адрес один на одну сущность
|
||||||
* — id до и после проведения тот же, поэтому разводим не роутом, а статусом:
|
* — id до и после проведения тот же, поэтому разводим не роутом, а статусом:
|
||||||
* так работает и ссылка из списка, и переход сразу после «Провести».
|
* так работает и ссылка из списка, и переход сразу после «Провести».
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { toast } from "sonner";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import PartsPickerModal from "@/components/repairs/PartsPickerModal";
|
import PartsPickerModal from "@/components/repairs/PartsPickerModal";
|
||||||
|
import QtyStepper from "@/components/repairs/QtyStepper";
|
||||||
import { fmtMoney } from "@/lib/repairsFeed";
|
import { fmtMoney } from "@/lib/repairsFeed";
|
||||||
import {
|
import {
|
||||||
addFind,
|
addFind,
|
||||||
|
apiErrorText,
|
||||||
applyStocktake,
|
applyStocktake,
|
||||||
countLine,
|
countLine,
|
||||||
deleteStocktake,
|
deleteStocktake,
|
||||||
|
deleteStocktakeLine,
|
||||||
getStocktake,
|
getStocktake,
|
||||||
|
patchFind,
|
||||||
type StocktakeDetail,
|
type StocktakeDetail,
|
||||||
type StocktakeLine,
|
type StocktakeLine,
|
||||||
} from "@/api/warehouse";
|
} from "@/api/warehouse";
|
||||||
@@ -44,6 +62,11 @@ export default function StocktakeSessionPage() {
|
|||||||
const [onlyPending, setOnlyPending] = useState(true);
|
const [onlyPending, setOnlyPending] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
// Выбранная в пикере деталь ждёт количество: сколько нашли, спрашиваем ДО
|
||||||
|
// отправки — иначе коробка из восьми уходит в учёт одной штукой.
|
||||||
|
const [findDraft, setFindDraft] = useState<PartCatalogItem | null>(null);
|
||||||
|
// Строка, в поле которой сейчас курсор: её фильтр не трогает (см. шапку).
|
||||||
|
const [focusedId, setFocusedId] = useState<number | null>(null);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["wh-stocktake", stId],
|
queryKey: ["wh-stocktake", stId],
|
||||||
@@ -56,13 +79,28 @@ export default function StocktakeSessionPage() {
|
|||||||
const st = data ?? query.data ?? null;
|
const st = data ?? query.data ?? null;
|
||||||
const isDraft = st?.status === "draft";
|
const isDraft = st?.status === "draft";
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
// Обход и находки — разные списки (см. шапку файла). В черновике находка
|
||||||
const lines = st?.lines ?? [];
|
// опознаётся по batch_id = null; после проведения признак затирается, но
|
||||||
return onlyPending ? lines.filter((l) => l.qty_counted == null) : lines;
|
// проведённая сессия сюда не доходит.
|
||||||
}, [st, onlyPending]);
|
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(() => {
|
const totals = useMemo(() => {
|
||||||
let shortQty = 0, shortAmt = 0, surQty = 0, surAmt = 0;
|
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)) };
|
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 {
|
try {
|
||||||
applyLine(await countLine(stId, line.id, { qty_counted: value }));
|
applyLine(await countLine(stId, line.id, { qty_counted: value }));
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} 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 {
|
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) => {
|
setData((cur) => {
|
||||||
const base = cur ?? query.data ?? null;
|
const base = cur ?? query.data ?? null;
|
||||||
return base ? { ...base, lines: [...base.lines, line] } : cur;
|
return base ? { ...base, lines: [...base.lines, line] } : cur;
|
||||||
});
|
});
|
||||||
toast.success(`Находка добавлена: ${item.name}`);
|
toast.success(`Находка добавлена: ${item.name}, ${qty} шт`);
|
||||||
} catch (e) {
|
} 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));
|
setData(await applyStocktake(stId));
|
||||||
toast.success("Инвентаризация проведена");
|
toast.success("Инвентаризация проведена");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error).message || "Не удалось провести");
|
toast.error(await apiErrorText(e, "Не удалось провести"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -134,7 +206,7 @@ export default function StocktakeSessionPage() {
|
|||||||
await deleteStocktake(stId);
|
await deleteStocktake(stId);
|
||||||
navigate("/wh/stocktake", { replace: true });
|
navigate("/wh/stocktake", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error).message || "Не удалось удалить");
|
toast.error(await apiErrorText(e, "Не удалось удалить"));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -160,7 +232,10 @@ export default function StocktakeSessionPage() {
|
|||||||
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
|
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<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 && (
|
{isDraft && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -206,6 +281,27 @@ export default function StocktakeSessionPage() {
|
|||||||
</Button>
|
</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 && (
|
{st && visible.length === 0 && (
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
{onlyPending && totalLines > 0
|
{onlyPending && totalLines > 0
|
||||||
@@ -216,7 +312,13 @@ export default function StocktakeSessionPage() {
|
|||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{visible.map((l) => (
|
{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>
|
</ul>
|
||||||
|
|
||||||
@@ -236,13 +338,15 @@ export default function StocktakeSessionPage() {
|
|||||||
<span className={totals.shortQty ? "text-destructive font-medium" : "text-muted-foreground"}>
|
<span className={totals.shortQty ? "text-destructive font-medium" : "text-muted-foreground"}>
|
||||||
недостача {totals.shortQty} · {fmtMoney(totals.shortAmt)} ₽
|
недостача {totals.shortQty} · {fmtMoney(totals.shortAmt)} ₽
|
||||||
</span>
|
</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)} ₽
|
излишек {totals.surQty} · {fmtMoney(totals.surAmt)} ₽
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDraft && (
|
{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" /> : "Провести"}
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Провести"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -255,28 +359,202 @@ export default function StocktakeSessionPage() {
|
|||||||
<PartsPickerModal
|
<PartsPickerModal
|
||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
carId={null}
|
carId={null}
|
||||||
onPick={(item) => void onFind(item)}
|
onPick={(item) => { setPickerOpen(false); setFindDraft(item); }}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{findDraft && (
|
||||||
|
<FindQtyDialog
|
||||||
|
item={findDraft}
|
||||||
|
onCancel={() => setFindDraft(null)}
|
||||||
|
onConfirm={(qty) => void onFind(findDraft, qty)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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({
|
function LineRow({
|
||||||
line, editable, onSet,
|
line, editable, onSet, onFocusChange,
|
||||||
}: {
|
}: {
|
||||||
line: StocktakeLine;
|
line: StocktakeLine;
|
||||||
editable: boolean;
|
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 [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 =
|
const tone =
|
||||||
line.resolution === "shortage" ? "border-destructive/40 bg-destructive/5"
|
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"
|
: line.resolution === "match" ? "border-border bg-muted/30"
|
||||||
: "border-border bg-card";
|
: "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 (
|
return (
|
||||||
<li className={`rounded-lg border p-3 ${tone}`}>
|
<li className={`rounded-lg border p-3 ${tone}`}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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-sm font-medium truncate">{line.part_name}</div>
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
{line.article ? `арт. ${line.article} · ` : ""}
|
{line.article ? `арт. ${line.article} · ` : ""}
|
||||||
{line.batch_id == null ? "находка" : `числится ${line.qty_expected}`}
|
числится {line.qty_expected}
|
||||||
{line.place_code ? ` · ${line.place_code}` : ""}
|
{line.place_code ? ` · ${line.place_code}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{line.delta !== 0 && (
|
{line.delta !== 0 && (
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold whitespace-nowrap ${
|
className={`text-sm font-semibold whitespace-nowrap tabular-nums ${
|
||||||
line.delta < 0 ? "text-destructive" : "text-emerald-700"
|
line.delta < 0 ? "text-destructive" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{line.delta > 0 ? `+${line.delta}` : line.delta}
|
{line.delta > 0 ? `+${line.delta}` : line.delta}
|
||||||
@@ -307,27 +585,15 @@ function LineRow({
|
|||||||
min={0}
|
min={0}
|
||||||
placeholder="сколько нашли"
|
placeholder="сколько нашли"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => onType(e.target.value)}
|
||||||
onBlur={() => {
|
onFocus={() => onFocusChange(true)}
|
||||||
const v = draft.trim() === "" ? null : Math.max(0, Number(draft));
|
onBlur={onLeave}
|
||||||
if (v !== line.qty_counted) onSet(v);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
{line.batch_id != null && (
|
<Button variant="outline" size="sm" onClick={() => tap(line.qty_expected)}>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { setDraft(String(line.qty_expected)); onSet(line.qty_expected); }}
|
|
||||||
>
|
|
||||||
Сходится
|
Сходится
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button variant="outline" size="sm" onClick={() => tap(0)}>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { setDraft("0"); onSet(0); }}
|
|
||||||
>
|
|
||||||
Нет
|
Нет
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,3 +601,78 @@ function LineRow({
|
|||||||
</li>
|
</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