wh: разделы «Приёмка» и «Инвентаризация» в PWA (админам, мобильная вёрстка)
Приёмка. Главное действие — снять накладную/чек камерой; несколько листов уходят одним документом. Кадр ужимается до 2200 px на клиенте: сервер всё равно приводит к этому размеру, а на мобильном интернете в гараже разница в секундах загрузки заметная. Распознанное проверяется рядом с фото документа — строки правятся на месте, поставщик выбирается или заводится прямо с накладной. «Провести» приходует товар в выбранную зону. Инвентаризация. Запуск спрашивает зону: весь склад одной сессией — это сотни строк, которые никто не обойдёт за раз. На обходе по умолчанию видны только непосчитанные, посчитанная сразу уходит из списка. Пустое поле — «ещё не считали», ноль вводится кнопкой «Нет» (это уже недостача). Находки подбираются из складского каталога общим пикером. Внизу постоянно висят недостача и излишек в штуках и рублях. Доступ: плитки в меню и маршруты закрыты RequireAdmin (тот же гейт на сервере — клиентская проверка нужна, чтобы прямая ссылка не открывала экран с ошибкой вместо «вам сюда нельзя»). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { getMe } from "@/api/me";
|
||||||
import { useMeSync } from "@/hooks/useMeSync";
|
import { useMeSync } from "@/hooks/useMeSync";
|
||||||
import Login from "@/pages/Login";
|
import Login from "@/pages/Login";
|
||||||
import ActionMenu from "@/pages/ActionMenu";
|
import ActionMenu from "@/pages/ActionMenu";
|
||||||
@@ -13,6 +15,10 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage";
|
|||||||
import EditRepairPage from "@/pages/repairs/EditRepairPage";
|
import EditRepairPage from "@/pages/repairs/EditRepairPage";
|
||||||
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
||||||
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
|
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 }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuth((s) => s.token);
|
const token = useAuth((s) => s.token);
|
||||||
@@ -20,6 +26,20 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
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() {
|
export default function App() {
|
||||||
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
||||||
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
||||||
@@ -42,6 +62,11 @@ export default function App() {
|
|||||||
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||||
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
|
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
|
||||||
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: "🔧",
|
emoji: "🔧",
|
||||||
onClick: () => navigate("/repairs"),
|
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 (
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user