diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 9ab210c..a930a43 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -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 ; + if (meQuery.isLoading) return null; + if (!meQuery.data?.is_admin) return ; + return <>{children}>; +} + export default function App() { // У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login // с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел @@ -42,6 +62,11 @@ export default function App() { } /> } /> } /> + {/* Склад — приёмка и инвентаризация с телефона (пока только админы). */} + } /> + } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts new file mode 100644 index 0000000..4ab3a25 --- /dev/null +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -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 { + return api.get("wh/places").json(); +} + +export async function getSuppliers(): Promise { + return api.get("wh/suppliers").json(); +} + +// ── Приёмка ──────────────────────────────────────────────────────────────── + +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 { + 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(); +} + +export async function getDrafts(status = "pending"): Promise { + return api.get(`wh/receiving/drafts?status=${status}`).json(); +} + +export async function getDraft(id: number): Promise { + return api.get(`wh/receiving/drafts/${id}`).json(); +} + +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 { + return api.patch(`wh/receiving/drafts/${id}`, { json: body }).json(); +} + +export async function createDraftSupplier( + id: number, + body: { name: string; legal_entity?: string | null }, +): Promise { + return api.post(`wh/receiving/drafts/${id}/supplier`, { json: body }).json(); +} + +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 { + return api.post(`wh/receiving/drafts/${id}/lines`, { json: body }).json(); +} + +export async function patchDraftLine( + id: number, + lineId: number, + body: DraftLinePatch, +): Promise { + return api.patch(`wh/receiving/drafts/${id}/lines/${lineId}`, { json: body }).json(); +} + +export async function deleteDraftLine(id: number, lineId: number): Promise { + return api.delete(`wh/receiving/drafts/${id}/lines/${lineId}`).json(); +} + +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 { + return api.post(`wh/receiving/drafts/${id}/post`, { json: body }).json(); +} + +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 { + return api.get(`wh/receipts?limit=${limit}`).json(); +} + +// ── Инвентаризация ───────────────────────────────────────────────────────── + +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 { + return api.get("wh/stocktakes").json(); +} + +export async function getStocktake(id: number): Promise { + return api.get(`wh/stocktakes/${id}`).json(); +} + +export async function createStocktake(body: { + place_id: number | null; + comment?: string | null; +}): Promise { + return api.post("wh/stocktakes", { json: body }).json(); +} + +export async function countLine( + stocktakeId: number, + lineId: number, + body: { qty_counted: number | null; comment?: string | null }, +): Promise { + return api + .patch(`wh/stocktakes/${stocktakeId}/lines/${lineId}`, { json: body }) + .json(); +} + +export async function addFind( + stocktakeId: number, + body: { part_id: number; qty: number; place_id?: number | null; comment?: string | null }, +): Promise { + return api.post(`wh/stocktakes/${stocktakeId}/finds`, { json: body }).json(); +} + +export async function applyStocktake(id: number): Promise { + return api.post(`wh/stocktakes/${id}/apply`).json(); +} + +export async function deleteStocktake(id: number): Promise<{ ok: boolean }> { + return api.delete(`wh/stocktakes/${id}`).json<{ ok: boolean }>(); +} diff --git a/mechanic-pwa/frontend/src/lib/imageDownscale.ts b/mechanic-pwa/frontend/src/lib/imageDownscale.ts new file mode 100644 index 0000000..92afcb2 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/imageDownscale.ts @@ -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 { + // Если что-то в цепочке не поддержано (старый 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((resolve) => + canvas.toBlob(resolve, "image/jpeg", QUALITY), + ); + return blob ?? file; + } catch { + return file; + } +} diff --git a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx index b3d43c6..da2db62 100644 --- a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx +++ b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx @@ -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 ( diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx new file mode 100644 index 0000000..27b2fd0 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [placeId, setPlaceId] = useState(null); + const [lightbox, setLightbox] = useState(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) => { + 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 ( + + + + navigate("/wh/receiving")} className="-ml-2"> + + Назад + + Проверка накладной + + + + + + {loading && } + + {draft && ( + <> + {/* Фото документа — сверять глазами по ходу правки */} + {draft.photo_urls.length > 0 && ( + + {draft.photo_urls.map((u) => ( + setLightbox(u)} className="flex-shrink-0"> + + + ))} + + )} + + {/* Шапка документа */} + + + Поставщик + {draft.supplier_id ? ( + void mutate(() => + patchDraft(draft.id, { supplier_id: Number(e.target.value) }))} + > + {(suppliersQuery.data ?? []).map((s) => ( + {s.name} + ))} + + ) : ( + + + Не определён{draft.legal_entity ? ` — в документе «${draft.legal_entity}»` : ""} + + + e.target.value && void mutate(() => + patchDraft(draft.id, { supplier_id: Number(e.target.value) }))} + > + Выбрать из списка… + {(suppliersQuery.data ?? []).map((s) => ( + {s.name} + ))} + + {draft.legal_entity && ( + void mutate(() => + createDraftSupplier(draft.id, { + name: draft.legal_entity!, legal_entity: draft.legal_entity, + }))} + > + Завести + + )} + + + )} + + + + + Номер документа + e.target.value !== draft.ext_order_id && + void mutate(() => patchDraft(draft.id, { doc_number: e.target.value }))} + /> + + + Дата + void mutate(() => + patchDraft(draft.id, { order_date: e.target.value || null }))} + /> + + + + + Зона хранения (куда кладём) + setPlaceId(e.target.value ? Number(e.target.value) : null)} + > + Без зоны + {(placesQuery.data ?? []).map((p) => ( + {p.code} — {p.name} + ))} + + + + + {/* Позиции */} + + + Позиции · {draft.lines.length} + {unmatched > 0 && ( + + новых карточек: {unmatched} + + )} + + + {draft.lines.map((ln) => ( + + + {ln.thumb_url && ( + + )} + e.target.value !== ln.name && + void mutate(() => patchDraftLine(draft.id, ln.id, { name: e.target.value }))} + /> + void mutate(() => deleteDraftLine(draft.id, ln.id))} + > + + + + + + Артикул + (e.target.value || null) !== ln.article && + void mutate(() => patchDraftLine(draft.id, ln.id, { article: e.target.value || null }))} + /> + + + Кол-во + Number(e.target.value) !== ln.qty && + void mutate(() => patchDraftLine(draft.id, ln.id, { + qty: Math.max(1, Number(e.target.value) || 1), + }))} + /> + + + Цена + Number(e.target.value) !== Number(ln.unit_price) && + void mutate(() => patchDraftLine(draft.id, ln.id, { + unit_price: Number(e.target.value) || 0, + }))} + /> + + + + + {ln.matched_part_id + ? `на складе: ${ln.matched_part_name ?? "карточка найдена"}` + : "новая карточка — заведётся при проведении"} + + + {fmtMoney(lineSum(ln.qty, ln.unit_price))} ₽ + + + + ))} + + 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" + > + Добавить позицию + + + + + Отклонить накладную + + > + )} + + + {/* Итого + проведение */} + {draft && ( + + )} + + {lightbox && ( + setLightbox(null)}> + + + + + + + + + + )} + + ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx new file mode 100644 index 0000000..141541a --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx @@ -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(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 ( + + + + navigate("/")} className="-ml-2"> + + Назад + + Приёмка + + + + + + {/* Главное действие */} + + + {scanning ? ( + + ) : ( + + )} + + {scanning ? "Распознаём накладную…" : "Снять накладную или чек"} + + + {scanning + ? "Это занимает до минуты — не закрывайте экран" + : "Сфотографируйте документ целиком. Несколько листов — снимите подряд, они уйдут одним документом."} + + + void onFiles(e.target.files)} + /> + + + {/* Черновики на проверке */} + + + На проверке{drafts.length > 0 ? ` · ${drafts.length}` : ""} + + {draftsQuery.isLoading ? ( + + ) : drafts.length === 0 ? ( + + Нечего проверять — все накладные проведены. + + ) : ( + + {drafts.map((d) => ( + + 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" + > + + + + {d.supplier_name || d.legal_entity || "Поставщик не определён"} + + + {d.ext_order_id} · {fmtDate(d.order_date)} · позиций {d.lines.length} + + + + {d.total_price != null ? `${fmtMoney(Number(d.total_price))} ₽` : ""} + + + + + ))} + + )} + + + {/* Последние приходы */} + + Последние приходы + {receiptsQuery.isLoading ? ( + + ) : receipts.length === 0 ? ( + Приходов пока нет. + ) : ( + + {receipts.map((r) => ( + + + + {r.supplier_name || "—"} + + {r.doc_number ? `${r.doc_number} · ` : ""} + {fmtDate(r.doc_date)} · позиций {r.lines_count} + {r.posted_by_name ? ` · ${r.posted_by_name}` : ""} + + + + {fmtMoney(Number(r.total_amount))} ₽ + + + ))} + + )} + + + + ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx new file mode 100644 index 0000000..5189d02 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakePage.tsx @@ -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(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 ( + + + + navigate("/")} className="-ml-2"> + + Назад + + Инвентаризация + setNewOpen(true)}> + + Начать + + + + + + {listQuery.isLoading && } + + {!listQuery.isLoading && items.length === 0 && ( + + + + + Пересчётов ещё не было + + Начните с одной зоны — приложение соберёт список того, что там числится. + + setNewOpen(true)}> + Начать пересчёт + + + )} + + + {items.map((st) => ( + + navigate(`/wh/stocktake/${st.id}`)} + className="w-full rounded-lg border bg-card p-3 text-left hover:bg-accent/40" + > + + + + {st.place_code ? `Зона ${st.place_code}` : "Весь склад"} + + + + {fmtDateTime(st.started_at)} + {st.author_name ? ` · ${st.author_name}` : ""} + + + + + + + + ))} + + + + {/* Лист выбора зоны */} + {newOpen && ( + setNewOpen(false)}> + e.stopPropagation()} + > + Что пересчитываем + + Зона хранения + setPlaceId(e.target.value ? Number(e.target.value) : null)} + > + Весь склад (долго) + {(placesQuery.data ?? []).map((p) => ( + {p.code} — {p.name} + ))} + + + Список соберётся из того, что числится в зоне. Найденное сверх списка + добавляется прямо во время обхода. + + + + setNewOpen(false)} className="flex-1"> + Отмена + + + {creating ? "Готовим…" : "Начать"} + + + + + )} + + ); +} + +function StatusChip({ status }: { status: string }) { + const done = status !== "draft"; + return ( + + {done ? "проведена" : "идёт"} + + ); +} + +function Progress({ st }: { st: Stocktake }) { + const pct = st.lines_total > 0 ? Math.round((st.lines_counted / st.lines_total) * 100) : 0; + return ( + + + + + + + посчитано {st.lines_counted} из {st.lines_total} + + + {st.shortage_qty > 0 && ( + + −{st.shortage_qty} ({fmtMoney(Number(st.shortage_amount))} ₽) + + )} + {st.surplus_qty > 0 && ( + + +{st.surplus_qty} ({fmtMoney(Number(st.surplus_amount))} ₽) + + )} + + + + ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx new file mode 100644 index 0000000..0d87f01 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/StocktakeSessionPage.tsx @@ -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(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 ( + + + + navigate("/wh/stocktake")} className="-ml-2"> + + Назад + + + {st?.place_code ? `Зона ${st.place_code}` : "Весь склад"} + + + + {st && ( + + + + + + посчитано {counted} из {totalLines} + {isDraft && ( + setOnlyPending((v) => !v)} + className="font-medium text-primary underline-offset-2 hover:underline" + > + {onlyPending ? "Показать все" : "Только непосчитанные"} + + )} + + + )} + + + + {query.isLoading && } + + {st && !isDraft && ( + + Инвентаризация проведена — расхождения уже в журнале склада, правки закрыты. + + )} + + {st && isDraft && ( + setPickerOpen(true)} className="w-full"> + + Нашёл то, чего нет в списке + + )} + + {st && visible.length === 0 && ( + + {onlyPending && totalLines > 0 + ? "Всё обошли. Проверьте итог внизу и проводите." + : "В этой зоне ничего не числится — добавьте находки, если что-то лежит."} + + )} + + + {visible.map((l) => ( + void setCount(l, v)} /> + ))} + + + {st && isDraft && ( + + Удалить инвентаризацию + + )} + + + {st && ( + + )} + + {/* Находка выбирается из общего каталога — машины тут нет, фильтр + применимости не при чём. */} + void onFind(item)} + onClose={() => setPickerOpen(false)} + /> + + ); +} + +function LineRow({ + line, editable, onSet, +}: { + line: StocktakeLine; + editable: boolean; + onSet: (v: number | null) => void; +}) { + const [draft, setDraft] = useState(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 ( + + + + {line.part_name} + + {line.article ? `арт. ${line.article} · ` : ""} + {line.batch_id == null ? "находка" : `числится ${line.qty_expected}`} + {line.place_code ? ` · ${line.place_code}` : ""} + + + {line.delta !== 0 && ( + + {line.delta > 0 ? `+${line.delta}` : line.delta} + + )} + + + {editable && ( + + 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 && ( + { setDraft(String(line.qty_expected)); onSet(line.qty_expected); }} + > + Сходится + + )} + { setDraft("0"); onSet(0); }} + > + Нет + + + )} + + ); +}
+ Начните с одной зоны — приложение соберёт список того, что там числится. +
+ Список соберётся из того, что числится в зоне. Найденное сверх списка + добавляется прямо во время обхода. +