wh: разделы «Приёмка» и «Инвентаризация» в PWA (админам, мобильная вёрстка)

Приёмка. Главное действие — снять накладную/чек камерой; несколько листов
уходят одним документом. Кадр ужимается до 2200 px на клиенте: сервер всё
равно приводит к этому размеру, а на мобильном интернете в гараже разница
в секундах загрузки заметная. Распознанное проверяется рядом с фото
документа — строки правятся на месте, поставщик выбирается или заводится
прямо с накладной. «Провести» приходует товар в выбранную зону.

Инвентаризация. Запуск спрашивает зону: весь склад одной сессией — это
сотни строк, которые никто не обойдёт за раз. На обходе по умолчанию видны
только непосчитанные, посчитанная сразу уходит из списка. Пустое поле —
«ещё не считали», ноль вводится кнопкой «Нет» (это уже недостача). Находки
подбираются из складского каталога общим пикером. Внизу постоянно висят
недостача и излишек в штуках и рублях.

Доступ: плитки в меню и маршруты закрыты RequireAdmin (тот же гейт на
сервере — клиентская проверка нужна, чтобы прямая ссылка не открывала
экран с ошибкой вместо «вам сюда нельзя»).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:23:06 +10:00
co-authored by Claude Opus 4.8
parent 2c02d15a10
commit 26c0261784
8 changed files with 1407 additions and 0 deletions
+25
View File
@@ -1,5 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/store/auth";
import { getMe } from "@/api/me";
import { useMeSync } from "@/hooks/useMeSync";
import Login from "@/pages/Login";
import ActionMenu from "@/pages/ActionMenu";
@@ -13,6 +15,10 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage";
import EditRepairPage from "@/pages/repairs/EditRepairPage";
import CarSelectPage from "@/pages/repairs/CarSelectPage";
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
import ReceivingPage from "@/pages/warehouse/ReceivingPage";
import ReceivingDraftPage from "@/pages/warehouse/ReceivingDraftPage";
import StocktakePage from "@/pages/warehouse/StocktakePage";
import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage";
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuth((s) => s.token);
@@ -20,6 +26,20 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/**
* Складские разделы пока только админам. Сервер и так отдаёт 403, но без
* клиентской проверки прямой ссылкой можно было бы открыть пустой экран с
* ошибкой — это читается как поломка, а не как «вам сюда нельзя».
*/
function RequireAdmin({ children }: { children: React.ReactNode }) {
const token = useAuth((s) => s.token);
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000, enabled: !!token });
if (!token) return <Navigate to="/login" replace />;
if (meQuery.isLoading) return null;
if (!meQuery.data?.is_admin) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
@@ -42,6 +62,11 @@ export default function App() {
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
{/* Склад — приёмка и инвентаризация с телефона (пока только админы). */}
<Route path="/wh/receiving" element={<RequireAdmin><ReceivingPage /></RequireAdmin>} />
<Route path="/wh/receiving/:id" element={<RequireAdmin><ReceivingDraftPage /></RequireAdmin>} />
<Route path="/wh/stocktake" element={<RequireAdmin><StocktakePage /></RequireAdmin>} />
<Route path="/wh/stocktake/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
+250
View File
@@ -0,0 +1,250 @@
/**
* Склад в PWA — приёмка и инвентаризация (эндпоинты /v1/mechanic/wh/*).
*
* Разделы админские: сервер отдаёт 403 всем остальным, поэтому клиент
* дополнительно прячет плитки по me.is_admin — чтобы механик не упирался в
* ошибку там, куда ему не надо.
*/
import { api } from "./client";
// ── Справочники ────────────────────────────────────────────────────────────
export interface WhRef {
id: number;
code: string;
name: string;
}
export async function getPlaces(): Promise<WhRef[]> {
return api.get("wh/places").json<WhRef[]>();
}
export async function getSuppliers(): Promise<WhRef[]> {
return api.get("wh/suppliers").json<WhRef[]>();
}
// ── Приёмка ────────────────────────────────────────────────────────────────
export interface DraftLine {
id: number;
brand: string | null;
article: string | null;
name: string;
qty: number;
unit_price: number | string;
matched_part_id: number | null;
matched_part_name: string | null;
thumb_url: string | null;
}
export interface Draft {
id: number;
source: string;
ext_order_id: string;
supplier_id: number | null;
supplier_name: string | null;
legal_entity: string | null;
order_date: string | null;
total_price: number | string | null;
status: string;
receipt_id: number | null;
created_at: string;
photo_urls: string[];
lines: DraftLine[];
}
export interface DraftListResponse {
items: Draft[];
total: number;
}
/** Фото накладной/чека → распознавание → черновик (приход НЕ создаётся). */
export async function scanInvoice(files: Blob[]): Promise<Draft> {
const fd = new FormData();
files.forEach((f, i) => fd.append("files", f, `page${i + 1}.jpg`));
// Распознавание идёт на vision-модели и занимает десятки секунд —
// клиентский таймаут по умолчанию (30 с) её не дожидается.
return api.post("wh/receiving/scan", { body: fd, timeout: 240_000 }).json<Draft>();
}
export async function getDrafts(status = "pending"): Promise<DraftListResponse> {
return api.get(`wh/receiving/drafts?status=${status}`).json<DraftListResponse>();
}
export async function getDraft(id: number): Promise<Draft> {
return api.get(`wh/receiving/drafts/${id}`).json<Draft>();
}
export interface DraftPatch {
supplier_id?: number | null;
order_date?: string | null;
doc_number?: string | null;
comment?: string | null;
}
export async function patchDraft(id: number, body: DraftPatch): Promise<Draft> {
return api.patch(`wh/receiving/drafts/${id}`, { json: body }).json<Draft>();
}
export async function createDraftSupplier(
id: number,
body: { name: string; legal_entity?: string | null },
): Promise<Draft> {
return api.post(`wh/receiving/drafts/${id}/supplier`, { json: body }).json<Draft>();
}
export interface DraftLinePatch {
article?: string | null;
name?: string | null;
qty?: number;
unit_price?: number;
matched_part_id?: number | null;
}
export async function addDraftLine(id: number, body: DraftLinePatch): Promise<Draft> {
return api.post(`wh/receiving/drafts/${id}/lines`, { json: body }).json<Draft>();
}
export async function patchDraftLine(
id: number,
lineId: number,
body: DraftLinePatch,
): Promise<Draft> {
return api.patch(`wh/receiving/drafts/${id}/lines/${lineId}`, { json: body }).json<Draft>();
}
export async function deleteDraftLine(id: number, lineId: number): Promise<Draft> {
return api.delete(`wh/receiving/drafts/${id}/lines/${lineId}`).json<Draft>();
}
export interface PostDraftResult {
ok: boolean;
receipt_id: number;
lines: number;
}
export async function postDraft(
id: number,
body: { place_id: number | null; doc_date?: string | null; exclude_line_ids?: number[] },
): Promise<PostDraftResult> {
return api.post(`wh/receiving/drafts/${id}/post`, { json: body }).json<PostDraftResult>();
}
export async function rejectDraft(id: number): Promise<{ ok: boolean }> {
return api.post(`wh/receiving/drafts/${id}/reject`).json<{ ok: boolean }>();
}
export interface ReceiptPreviewLine {
part_id: number;
name: string;
article: string | null;
qty: number;
thumb_url: string | null;
}
export interface Receipt {
id: number;
supplier_name: string | null;
doc_number: string | null;
doc_date: string;
total_amount: number | string;
lines_count: number;
posted_by_name: string | null;
preview_lines: ReceiptPreviewLine[];
}
export interface ReceiptListResponse {
items: Receipt[];
total: number;
total_sum: number | string;
month_total: number | string;
month_label: string;
}
export async function getReceipts(limit = 30): Promise<ReceiptListResponse> {
return api.get(`wh/receipts?limit=${limit}`).json<ReceiptListResponse>();
}
// ── Инвентаризация ─────────────────────────────────────────────────────────
export interface Stocktake {
id: number;
status: string; // draft | done
place_id: number | null;
place_code: string | null;
author_name: string | null;
started_at: string;
finished_at: string | null;
comment: string | null;
lines_total: number;
lines_counted: number;
shortage_qty: number;
shortage_amount: number | string;
surplus_qty: number;
surplus_amount: number | string;
}
export interface StocktakeLine {
id: number;
batch_id: number | null; // null = находка
part_id: number;
part_name: string;
article: string | null;
place_code: string | null;
qty_expected: number;
qty_counted: number | null;
unit_price: number | string | null;
resolution: "not_counted" | "match" | "shortage" | "surplus";
delta: number;
delta_amount: number | string;
comment: string | null;
}
export interface StocktakeDetail extends Stocktake {
lines: StocktakeLine[];
}
export interface StocktakeListResponse {
items: Stocktake[];
total: number;
}
export async function getStocktakes(): Promise<StocktakeListResponse> {
return api.get("wh/stocktakes").json<StocktakeListResponse>();
}
export async function getStocktake(id: number): Promise<StocktakeDetail> {
return api.get(`wh/stocktakes/${id}`).json<StocktakeDetail>();
}
export async function createStocktake(body: {
place_id: number | null;
comment?: string | null;
}): Promise<StocktakeDetail> {
return api.post("wh/stocktakes", { json: body }).json<StocktakeDetail>();
}
export async function countLine(
stocktakeId: number,
lineId: number,
body: { qty_counted: number | null; comment?: string | null },
): Promise<StocktakeLine> {
return api
.patch(`wh/stocktakes/${stocktakeId}/lines/${lineId}`, { json: body })
.json<StocktakeLine>();
}
export async function addFind(
stocktakeId: number,
body: { part_id: number; qty: number; place_id?: number | null; comment?: string | null },
): Promise<StocktakeLine> {
return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json<StocktakeLine>();
}
export async function applyStocktake(id: number): Promise<StocktakeDetail> {
return api.post(`wh/stocktakes/${id}/apply`).json<StocktakeDetail>();
}
export async function deleteStocktake(id: number): Promise<{ ok: boolean }> {
return api.delete(`wh/stocktakes/${id}`).json<{ ok: boolean }>();
}
@@ -0,0 +1,43 @@
/**
* Ужать снимок перед отправкой на распознавание.
*
* Телефон отдаёт кадр на 4000+ px и 3–5 МБ, а сервер всё равно приводит
* накладную к 2200 px по длинной стороне — значит всё, что выше, это чистые
* секунды загрузки на мобильном интернете в гараже. Ужимаем на клиенте до
* того же предела: качество распознавания не меняется, отправка ускоряется
* в разы.
*
* 2200 — именно то, на чём работает vision-модель (см. invoice_ocr.py).
* Мельче нельзя: текст накладной и так на грани читаемости.
*/
const MAX_EDGE = 2200;
const QUALITY = 0.9;
export async function downscaleImage(file: File, maxEdge = MAX_EDGE): Promise<Blob> {
// Если что-то в цепочке не поддержано (старый WebView) — шлём оригинал:
// медленно, но рабочее лучше, чем ошибка.
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
if (scale >= 1) {
bitmap.close();
return file;
}
const canvas = document.createElement("canvas");
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", QUALITY),
);
return blob ?? file;
} catch {
return file;
}
}
@@ -34,6 +34,26 @@ export default function ActionMenu() {
emoji: "🔧",
onClick: () => navigate("/repairs"),
},
// Складские разделы правят остатки и деньги — пока только админам
// (тот же гейт стоит на сервере, здесь просто не показываем лишнего).
...(meQuery.data?.is_admin
? [
{
key: "receiving",
title: "Приёмка",
description: "Снять накладную камерой и оприходовать товар",
emoji: "📦",
onClick: () => navigate("/wh/receiving"),
},
{
key: "stocktake",
title: "Инвентаризация",
description: "Пересчитать зону склада и провести расхождения",
emoji: "📋",
onClick: () => navigate("/wh/stocktake"),
},
]
: []),
];
return (
@@ -0,0 +1,375 @@
/**
* Проверка распознанной накладной перед проведением.
*
* Распознавание почти никогда не идеально: путается артикул, съезжает цена,
* поставщик может быть новым. Поэтому экран построен как сверка — фото
* документа сверху всегда под рукой, а каждая строка правится на месте.
*
* Приход создаётся только по кнопке «Провести»: до неё черновик ничего не
* меняет на складе, и его можно спокойно бросить.
*/
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, Loader2, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { fmtMoney } from "@/lib/repairsFeed";
import {
addDraftLine,
createDraftSupplier,
deleteDraftLine,
getDraft,
getPlaces,
getSuppliers,
patchDraft,
patchDraftLine,
postDraft,
rejectDraft,
type Draft,
} from "@/api/warehouse";
function lineSum(qty: number, price: number | string): number {
return qty * Number(price || 0);
}
export default function ReceivingDraftPage() {
const { id } = useParams<{ id: string }>();
const draftId = Number(id);
const navigate = useNavigate();
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [placeId, setPlaceId] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<string | null>(null);
const placesQuery = useQuery({ queryKey: ["wh-places"], queryFn: getPlaces, staleTime: 300_000 });
const suppliersQuery = useQuery({
queryKey: ["wh-suppliers"],
queryFn: getSuppliers,
staleTime: 300_000,
});
useEffect(() => {
if (!draftId) return;
setLoading(true);
getDraft(draftId)
.then(setDraft)
.catch((e: Error) => toast.error(e.message || "Не удалось загрузить черновик"))
.finally(() => setLoading(false));
}, [draftId]);
// Обёртка над правками: сервер всегда возвращает черновик целиком, поэтому
// локально ничего не пересчитываем — берём его ответ как истину.
const mutate = async (fn: () => Promise<Draft>) => {
setBusy(true);
try {
setDraft(await fn());
} catch (e) {
toast.error((e as Error).message || "Не удалось сохранить");
} finally {
setBusy(false);
}
};
const total = (draft?.lines ?? []).reduce((s, l) => s + lineSum(l.qty, l.unit_price), 0);
const unmatched = (draft?.lines ?? []).filter((l) => !l.matched_part_id).length;
const onPost = async () => {
if (!draft) return;
if (!draft.supplier_id) {
toast.error("Укажите поставщика — без него приход не провести");
return;
}
if (draft.lines.length === 0) {
toast.error("В накладной нет позиций");
return;
}
setBusy(true);
try {
const r = await postDraft(draft.id, { place_id: placeId, doc_date: draft.order_date });
toast.success(`Приход проведён · позиций ${r.lines}`);
navigate("/wh/receiving", { replace: true });
} catch (e) {
toast.error((e as Error).message || "Не удалось провести приход");
setBusy(false);
}
};
const onReject = async () => {
if (!draft) return;
if (!window.confirm("Отклонить накладную? Остатки не изменятся, черновик уйдёт из списка.")) return;
setBusy(true);
try {
await rejectDraft(draft.id);
toast.success("Черновик отклонён");
navigate("/wh/receiving", { replace: true });
} catch (e) {
toast.error((e as Error).message || "Не удалось отклонить");
setBusy(false);
}
};
return (
<div className="min-h-screen bg-background flex flex-col pb-24">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/wh/receiving")} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">Проверка накладной</h1>
<span className="w-16" />
</div>
</header>
<main className="flex-1 container max-w-2xl py-4 space-y-5">
{loading && <div className="h-24 rounded-lg bg-muted animate-pulse" />}
{draft && (
<>
{/* Фото документа — сверять глазами по ходу правки */}
{draft.photo_urls.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{draft.photo_urls.map((u) => (
<button key={u} type="button" onClick={() => setLightbox(u)} className="flex-shrink-0">
<img
src={u}
alt="Накладная"
loading="lazy"
className="h-28 w-auto rounded-md border bg-muted object-cover"
/>
</button>
))}
</div>
)}
{/* Шапка документа */}
<section className="rounded-lg border bg-card p-3 space-y-3">
<div>
<Label className="text-xs">Поставщик</Label>
{draft.supplier_id ? (
<select
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={draft.supplier_id}
disabled={busy}
onChange={(e) => void mutate(() =>
patchDraft(draft.id, { supplier_id: Number(e.target.value) }))}
>
{(suppliersQuery.data ?? []).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
) : (
<div className="mt-1 space-y-2">
<div className="text-sm text-destructive">
Не определён{draft.legal_entity ? ` — в документе «${draft.legal_entity}»` : ""}
</div>
<div className="flex gap-2">
<select
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
defaultValue=""
disabled={busy}
onChange={(e) => e.target.value && void mutate(() =>
patchDraft(draft.id, { supplier_id: Number(e.target.value) }))}
>
<option value="" disabled>Выбрать из списка</option>
{(suppliersQuery.data ?? []).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{draft.legal_entity && (
<Button
variant="outline"
disabled={busy}
onClick={() => void mutate(() =>
createDraftSupplier(draft.id, {
name: draft.legal_entity!, legal_entity: draft.legal_entity,
}))}
>
Завести
</Button>
)}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Номер документа</Label>
<Input
defaultValue={draft.ext_order_id}
disabled={busy}
onBlur={(e) => e.target.value !== draft.ext_order_id &&
void mutate(() => patchDraft(draft.id, { doc_number: e.target.value }))}
/>
</div>
<div>
<Label className="text-xs">Дата</Label>
<Input
type="date"
defaultValue={draft.order_date ?? ""}
disabled={busy}
onChange={(e) => void mutate(() =>
patchDraft(draft.id, { order_date: e.target.value || null }))}
/>
</div>
</div>
<div>
<Label className="text-xs">Зона хранения (куда кладём)</Label>
<select
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={placeId ?? ""}
onChange={(e) => setPlaceId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Без зоны</option>
{(placesQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</select>
</div>
</section>
{/* Позиции */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold">Позиции · {draft.lines.length}</h2>
{unmatched > 0 && (
<span className="text-xs text-amber-700">
новых карточек: {unmatched}
</span>
)}
</div>
{draft.lines.map((ln) => (
<div key={ln.id} className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-start gap-2">
{ln.thumb_url && (
<img
src={ln.thumb_url}
alt=""
loading="lazy"
className="h-10 w-10 rounded border object-cover flex-shrink-0"
/>
)}
<Input
defaultValue={ln.name}
disabled={busy}
className="flex-1"
onBlur={(e) => e.target.value !== ln.name &&
void mutate(() => patchDraftLine(draft.id, ln.id, { name: e.target.value }))}
/>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void mutate(() => deleteDraftLine(draft.id, ln.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[11px] text-muted-foreground">Артикул</Label>
<Input
defaultValue={ln.article ?? ""}
disabled={busy}
onBlur={(e) => (e.target.value || null) !== ln.article &&
void mutate(() => patchDraftLine(draft.id, ln.id, { article: e.target.value || null }))}
/>
</div>
<div>
<Label className="text-[11px] text-muted-foreground">Кол-во</Label>
<Input
type="number"
inputMode="numeric"
min={1}
defaultValue={ln.qty}
disabled={busy}
onBlur={(e) => Number(e.target.value) !== ln.qty &&
void mutate(() => patchDraftLine(draft.id, ln.id, {
qty: Math.max(1, Number(e.target.value) || 1),
}))}
/>
</div>
<div>
<Label className="text-[11px] text-muted-foreground">Цена</Label>
<Input
type="number"
inputMode="decimal"
min={0}
defaultValue={Number(ln.unit_price)}
disabled={busy}
onBlur={(e) => Number(e.target.value) !== Number(ln.unit_price) &&
void mutate(() => patchDraftLine(draft.id, ln.id, {
unit_price: Number(e.target.value) || 0,
}))}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className={ln.matched_part_id ? "text-muted-foreground" : "text-amber-700"}>
{ln.matched_part_id
? `на складе: ${ln.matched_part_name ?? "карточка найдена"}`
: "новая карточка — заведётся при проведении"}
</span>
<span className="font-medium">
{fmtMoney(lineSum(ln.qty, ln.unit_price))}
</span>
</div>
</div>
))}
<button
type="button"
disabled={busy}
onClick={() => void mutate(() =>
addDraftLine(draft.id, { name: "Новая позиция", qty: 1, unit_price: 0 }))}
className="w-full border-2 border-dashed rounded-md py-2.5 text-sm hover:bg-accent/40 flex items-center justify-center gap-1.5"
>
<Plus className="h-4 w-4" /> Добавить позицию
</button>
</section>
<Button variant="ghost" onClick={onReject} disabled={busy} className="w-full text-destructive">
Отклонить накладную
</Button>
</>
)}
</main>
{/* Итого + проведение */}
{draft && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background p-3 z-10">
<div className="container max-w-2xl flex items-center gap-3">
<div className="flex-1">
<div className="text-xs text-muted-foreground">Итого по накладной</div>
<div className="text-lg font-semibold tabular-nums">{fmtMoney(total)} </div>
</div>
<Button onClick={onPost} disabled={busy || draft.lines.length === 0} className="flex-1">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Провести"}
</Button>
</div>
</footer>
)}
{lightbox && (
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col" onClick={() => setLightbox(null)}>
<div className="flex justify-end p-3">
<button className="text-white p-2" aria-label="Закрыть">
<X className="h-6 w-6" />
</button>
</div>
<div className="flex-1 flex items-center justify-center p-3">
<img src={lightbox} alt="Накладная" className="max-w-full max-h-full object-contain" />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,189 @@
/**
* Приёмка товара — точка входа раздела.
*
* Главное действие вынесено наверх и занимает пол-экрана: снять накладную
* камерой. Всё остальное (черновики на проверке, последние приходы) — списки
* под ним. Приёмка делается стоя у коробок с телефоном в одной руке, поэтому
* кнопки крупные, а информации на строку минимум.
*/
import { useCallback, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Camera, ChevronLeft, ChevronRight, FileText, Loader2, PackageCheck } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { downscaleImage } from "@/lib/imageDownscale";
import { fmtMoney } from "@/lib/repairsFeed";
import { getDrafts, getReceipts, scanInvoice } from "@/api/warehouse";
function fmtDate(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long" }).format(d);
}
export default function ReceivingPage() {
const navigate = useNavigate();
const [scanning, setScanning] = useState(false);
const fileRef = useRef<HTMLInputElement | null>(null);
const draftsQuery = useQuery({
queryKey: ["wh-drafts"],
queryFn: () => getDrafts("pending"),
staleTime: 15_000,
});
const receiptsQuery = useQuery({
queryKey: ["wh-receipts"],
queryFn: () => getReceipts(20),
staleTime: 60_000,
});
const onFiles = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setScanning(true);
try {
// Накладная бывает на 2-3 листа — снимаем всё разом, распознаём как
// один документ (модель видит все страницы в одном запросе).
const prepared = await Promise.all(
Array.from(files).slice(0, 6).map((f) => downscaleImage(f)),
);
const draft = await scanInvoice(prepared);
toast.success(`Распознано позиций: ${draft.lines.length}`);
navigate(`/wh/receiving/${draft.id}`);
} catch (e) {
toast.error((e as Error).message || "Не удалось распознать накладную");
} finally {
setScanning(false);
if (fileRef.current) fileRef.current.value = "";
}
},
[navigate],
);
const drafts = draftsQuery.data?.items ?? [];
const receipts = receiptsQuery.data?.items ?? [];
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/")} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">Приёмка</h1>
<span className="w-16" />
</div>
</header>
<main className="flex-1 container max-w-2xl py-4 space-y-6">
{/* Главное действие */}
<label
className={`block rounded-xl border-2 border-dashed p-6 text-center ${
scanning ? "opacity-60 pointer-events-none" : "cursor-pointer hover:bg-accent/40"
}`}
>
<div className="flex flex-col items-center gap-2">
{scanning ? (
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
) : (
<Camera className="h-8 w-8 text-muted-foreground" />
)}
<div className="font-semibold">
{scanning ? "Распознаём накладную…" : "Снять накладную или чек"}
</div>
<div className="text-xs text-muted-foreground max-w-xs">
{scanning
? "Это занимает до минуты — не закрывайте экран"
: "Сфотографируйте документ целиком. Несколько листов — снимите подряд, они уйдут одним документом."}
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
multiple
className="hidden"
disabled={scanning}
onChange={(e) => void onFiles(e.target.files)}
/>
</label>
{/* Черновики на проверке */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
На проверке{drafts.length > 0 ? ` · ${drafts.length}` : ""}
</h2>
{draftsQuery.isLoading ? (
<div className="h-16 rounded-lg bg-muted animate-pulse" />
) : drafts.length === 0 ? (
<div className="text-sm text-muted-foreground">
Нечего проверять все накладные проведены.
</div>
) : (
<ul className="space-y-2">
{drafts.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => navigate(`/wh/receiving/${d.id}`)}
className="w-full rounded-lg border bg-card p-3 text-left hover:bg-accent/40 flex items-center gap-3"
>
<FileText className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{d.supplier_name || d.legal_entity || "Поставщик не определён"}
</div>
<div className="text-xs text-muted-foreground truncate">
{d.ext_order_id} · {fmtDate(d.order_date)} · позиций {d.lines.length}
</div>
</div>
<div className="text-sm font-medium whitespace-nowrap">
{d.total_price != null ? `${fmtMoney(Number(d.total_price))}` : ""}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
</li>
))}
</ul>
)}
</section>
{/* Последние приходы */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Последние приходы</h2>
{receiptsQuery.isLoading ? (
<div className="h-16 rounded-lg bg-muted animate-pulse" />
) : receipts.length === 0 ? (
<div className="text-sm text-muted-foreground">Приходов пока нет.</div>
) : (
<ul className="space-y-2">
{receipts.map((r) => (
<li
key={r.id}
className="rounded-lg border bg-card p-3 flex items-center gap-3"
>
<PackageCheck className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.supplier_name || "—"}</div>
<div className="text-xs text-muted-foreground truncate">
{r.doc_number ? `${r.doc_number} · ` : ""}
{fmtDate(r.doc_date)} · позиций {r.lines_count}
{r.posted_by_name ? ` · ${r.posted_by_name}` : ""}
</div>
</div>
<div className="text-sm font-medium whitespace-nowrap">
{fmtMoney(Number(r.total_amount))}
</div>
</li>
))}
</ul>
)}
</section>
</main>
</div>
);
}
@@ -0,0 +1,196 @@
/**
* Инвентаризация — список сессий и запуск новой.
*
* Пересчёт делают по зонам (стеллаж за стеллажом), поэтому запуск сразу
* спрашивает зону: «весь склад одной сессией» — это несколько сотен строк,
* которые никто не обойдёт за раз.
*/
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, ClipboardList, Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { fmtMoney } from "@/lib/repairsFeed";
import { createStocktake, getPlaces, getStocktakes, type Stocktake } from "@/api/warehouse";
function fmtDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit", month: "long", hour: "2-digit", minute: "2-digit",
}).format(d);
}
export default function StocktakePage() {
const navigate = useNavigate();
const [creating, setCreating] = useState(false);
const [newOpen, setNewOpen] = useState(false);
const [placeId, setPlaceId] = useState<number | null>(null);
const listQuery = useQuery({
queryKey: ["wh-stocktakes"],
queryFn: getStocktakes,
staleTime: 15_000,
});
const placesQuery = useQuery({ queryKey: ["wh-places"], queryFn: getPlaces, staleTime: 300_000 });
const start = async () => {
setCreating(true);
try {
const st = await createStocktake({ place_id: placeId });
if (st.lines_total === 0) {
toast.message("В этой зоне нечего считать — сессия пустая");
}
navigate(`/wh/stocktake/${st.id}`);
} catch (e) {
toast.error((e as Error).message || "Не удалось начать инвентаризацию");
setCreating(false);
}
};
const items = listQuery.data?.items ?? [];
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/")} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">Инвентаризация</h1>
<Button variant="default" size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Начать
</Button>
</div>
</header>
<main className="flex-1 container max-w-2xl py-4 space-y-3">
{listQuery.isLoading && <div className="h-20 rounded-lg bg-muted animate-pulse" />}
{!listQuery.isLoading && items.length === 0 && (
<div className="text-center py-16 px-4 space-y-3">
<div className="inline-flex rounded-full bg-muted p-4">
<ClipboardList className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold">Пересчётов ещё не было</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Начните с одной зоны приложение соберёт список того, что там числится.
</p>
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Начать пересчёт
</Button>
</div>
)}
<ul className="space-y-2">
{items.map((st) => (
<li key={st.id}>
<button
type="button"
onClick={() => navigate(`/wh/stocktake/${st.id}`)}
className="w-full rounded-lg border bg-card p-3 text-left hover:bg-accent/40"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{st.place_code ? `Зона ${st.place_code}` : "Весь склад"}
<StatusChip status={st.status} />
</div>
<div className="text-xs text-muted-foreground truncate">
{fmtDateTime(st.started_at)}
{st.author_name ? ` · ${st.author_name}` : ""}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
<Progress st={st} />
</button>
</li>
))}
</ul>
</main>
{/* Лист выбора зоны */}
{newOpen && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-end" onClick={() => setNewOpen(false)}>
<div
className="bg-background w-full rounded-t-xl p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold">Что пересчитываем</h3>
<div>
<Label className="text-xs">Зона хранения</Label>
<select
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={placeId ?? ""}
onChange={(e) => setPlaceId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Весь склад (долго)</option>
{(placesQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
Список соберётся из того, что числится в зоне. Найденное сверх списка
добавляется прямо во время обхода.
</p>
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => setNewOpen(false)} className="flex-1">
Отмена
</Button>
<Button onClick={start} disabled={creating} className="flex-1">
{creating ? "Готовим…" : "Начать"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function StatusChip({ status }: { status: string }) {
const done = status !== "draft";
return (
<span
className={`ml-2 align-middle rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
done ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
}`}
>
{done ? "проведена" : "идёт"}
</span>
);
}
function Progress({ st }: { st: Stocktake }) {
const pct = st.lines_total > 0 ? Math.round((st.lines_counted / st.lines_total) * 100) : 0;
return (
<div className="mt-2 space-y-1">
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
посчитано {st.lines_counted} из {st.lines_total}
</span>
<span className="space-x-2">
{st.shortage_qty > 0 && (
<span className="text-destructive">
{st.shortage_qty} ({fmtMoney(Number(st.shortage_amount))} )
</span>
)}
{st.surplus_qty > 0 && (
<span className="text-emerald-700">
+{st.surplus_qty} ({fmtMoney(Number(st.surplus_amount))} )
</span>
)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,309 @@
/**
* Обход зоны: ввод факта по строкам + находки.
*
* Экран рассчитан на «иду вдоль стеллажа с телефоном»: по умолчанию показаны
* только непосчитанные строки, посчитанная сразу уходит из списка — видно,
* сколько ещё осталось обойти. Переключатель возвращает весь список, если
* надо что-то перепроверить.
*
* Пустое поле — это «ещё не считали», а не ноль. Ноль означает «дошёл и не
* нашёл» и превратится в недостачу, поэтому он вводится явной кнопкой.
*/
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Loader2, Plus } 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 { fmtMoney } from "@/lib/repairsFeed";
import {
addFind,
applyStocktake,
countLine,
deleteStocktake,
getStocktake,
type StocktakeDetail,
type StocktakeLine,
} from "@/api/warehouse";
import type { PartCatalogItem } from "@/api/repairsCreate";
export default function StocktakeSessionPage() {
const { id } = useParams<{ id: string }>();
const stId = Number(id);
const navigate = useNavigate();
const [data, setData] = useState<StocktakeDetail | null>(null);
const [onlyPending, setOnlyPending] = useState(true);
const [busy, setBusy] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const query = useQuery({
queryKey: ["wh-stocktake", stId],
queryFn: () => getStocktake(stId),
staleTime: 5_000,
});
// Локальная копия — чтобы строка исчезала из списка сразу после ввода, не
// дожидаясь перезапроса всей сессии (их бывает несколько сотен).
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]);
const counted = (st?.lines ?? []).filter((l) => l.qty_counted != null).length;
const totalLines = st?.lines.length ?? 0;
const totals = useMemo(() => {
let shortQty = 0, shortAmt = 0, surQty = 0, surAmt = 0;
for (const l of st?.lines ?? []) {
if (l.delta < 0) { shortQty += -l.delta; shortAmt += -Number(l.delta_amount || 0); }
if (l.delta > 0) { surQty += l.delta; surAmt += Number(l.delta_amount || 0); }
}
return { shortQty, shortAmt, surQty, surAmt };
}, [st]);
const applyLine = (updated: StocktakeLine) =>
setData((cur) => {
const base = cur ?? query.data ?? null;
if (!base) return cur;
return { ...base, lines: base.lines.map((l) => (l.id === updated.id ? updated : l)) };
});
const setCount = async (line: StocktakeLine, value: number | null) => {
try {
applyLine(await countLine(stId, line.id, { qty_counted: value }));
} catch (e) {
toast.error((e as Error).message || "Не удалось сохранить");
}
};
const onFind = async (item: PartCatalogItem) => {
setPickerOpen(false);
try {
const line = await addFind(stId, { part_id: item.id, qty: 1 });
setData((cur) => {
const base = cur ?? query.data ?? null;
return base ? { ...base, lines: [...base.lines, line] } : cur;
});
toast.success(`Находка добавлена: ${item.name}`);
} catch (e) {
toast.error((e as Error).message || "Не удалось добавить находку");
}
};
const onApply = async () => {
if (!st) return;
const notCounted = totalLines - counted;
const msg = notCounted > 0
? `Не посчитано строк: ${notCounted}. Они будут пропущены (останутся как есть). Провести?`
: "Провести инвентаризацию? Расхождения станут движениями склада.";
if (!window.confirm(msg)) return;
setBusy(true);
try {
setData(await applyStocktake(stId));
toast.success("Инвентаризация проведена");
} catch (e) {
toast.error((e as Error).message || "Не удалось провести");
} finally {
setBusy(false);
}
};
const onDelete = async () => {
if (!window.confirm("Удалить инвентаризацию? Введённые данные пропадут.")) return;
setBusy(true);
try {
await deleteStocktake(stId);
navigate("/wh/stocktake", { replace: true });
} catch (e) {
toast.error((e as Error).message || "Не удалось удалить");
setBusy(false);
}
};
const pct = totalLines > 0 ? Math.round((counted / totalLines) * 100) : 0;
return (
<div className="min-h-screen bg-background flex flex-col pb-28">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/wh/stocktake")} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">
{st?.place_code ? `Зона ${st.place_code}` : "Весь склад"}
</h1>
<span className="w-16" />
</div>
{st && (
<div className="px-3 pb-2 space-y-1">
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<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>
{isDraft && (
<button
type="button"
onClick={() => setOnlyPending((v) => !v)}
className="font-medium text-primary underline-offset-2 hover:underline"
>
{onlyPending ? "Показать все" : "Только непосчитанные"}
</button>
)}
</div>
</div>
)}
</header>
<main className="flex-1 container max-w-2xl py-3 space-y-2">
{query.isLoading && <div className="h-20 rounded-lg bg-muted animate-pulse" />}
{st && !isDraft && (
<div className="rounded-md border bg-muted/40 p-3 text-sm">
Инвентаризация проведена расхождения уже в журнале склада, правки закрыты.
</div>
)}
{st && isDraft && (
<Button variant="outline" onClick={() => setPickerOpen(true)} className="w-full">
<Plus className="h-4 w-4 mr-1" />
Нашёл то, чего нет в списке
</Button>
)}
{st && visible.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
{onlyPending && totalLines > 0
? "Всё обошли. Проверьте итог внизу и проводите."
: "В этой зоне ничего не числится — добавьте находки, если что-то лежит."}
</div>
)}
<ul className="space-y-2">
{visible.map((l) => (
<LineRow key={l.id} line={l} editable={!!isDraft} onSet={(v) => void setCount(l, v)} />
))}
</ul>
{st && isDraft && (
<Button variant="ghost" onClick={onDelete} disabled={busy} className="w-full text-destructive">
Удалить инвентаризацию
</Button>
)}
</main>
{st && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background p-3 z-10">
<div className="container max-w-2xl flex items-center gap-3">
<div className="flex-1 text-xs space-y-0.5">
<div className="text-muted-foreground">Расхождения</div>
<div className="flex gap-3">
<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"}>
излишек {totals.surQty} · {fmtMoney(totals.surAmt)}
</span>
</div>
</div>
{isDraft && (
<Button onClick={onApply} disabled={busy || counted === 0}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Провести"}
</Button>
)}
</div>
</footer>
)}
{/* Находка выбирается из общего каталога — машины тут нет, фильтр
применимости не при чём. */}
<PartsPickerModal
open={pickerOpen}
carId={null}
onPick={(item) => void onFind(item)}
onClose={() => setPickerOpen(false)}
/>
</div>
);
}
function LineRow({
line, editable, onSet,
}: {
line: StocktakeLine;
editable: boolean;
onSet: (v: number | null) => void;
}) {
const [draft, setDraft] = useState<string>(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 === "match" ? "border-border bg-muted/30"
: "border-border bg-card";
return (
<li className={`rounded-lg border p-3 ${tone}`}>
<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.batch_id == null ? "находка" : `числится ${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"
}`}
>
{line.delta > 0 ? `+${line.delta}` : line.delta}
</span>
)}
</div>
{editable && (
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
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);
}}
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>
</div>
)}
</li>
);
}