diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index a930a43..2ad847f 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -17,6 +17,7 @@ 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 ReceiptDetailPage from "@/pages/warehouse/ReceiptDetailPage"; import StocktakePage from "@/pages/warehouse/StocktakePage"; import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage"; @@ -65,6 +66,7 @@ export default function App() { {/* Склад — приёмка и инвентаризация с телефона (пока только админы). */} } /> } /> + } /> } /> } /> } /> diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts index 4ab3a25..d90cba2 100644 --- a/mechanic-pwa/frontend/src/api/warehouse.ts +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -165,6 +165,32 @@ export async function getReceipts(limit = 30): Promise { return api.get(`wh/receipts?limit=${limit}`).json(); } +export interface ReceiptLine { + batch_id: number; + part_id: number; + /** Снимок имени на момент прихода — накладная исторический документ. */ + part_name: string; + article: string | null; + qty: number; + /** Сколько от этой партии ещё лежит на складе (может быть < 0). */ + qty_remaining: number; + unit_price: number | string; + amount: number | string; + place_code: string | null; + thumb_url: string | null; +} + +export interface ReceiptDetail extends Receipt { + supplier_id: number; + comment: string | null; + created_at: string | null; + lines: ReceiptLine[]; +} + +export async function getReceipt(id: number): Promise { + return api.get(`wh/receipts/${id}`).json(); +} + // ── Инвентаризация ───────────────────────────────────────────────────────── export interface Stocktake { diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceiptDetailPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceiptDetailPage.tsx new file mode 100644 index 0000000..70f0d71 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceiptDetailPage.tsx @@ -0,0 +1,134 @@ +/** + * Карточка проведённого прихода — только чтение. + * + * Накладная — исторический документ: имена и артикулы показываем снимками + * на момент приёмки, а не текущими из каталога. Правки нет намеренно: + * проведённый приход уже стал партиями и движениями, и «поправить» его + * означало бы разъехаться с журналом. Ошиблись — удаление/сторно делается + * в CRM, где видно все последствия. + * + * Единственное, что берётся из «сегодня» — сколько от каждой партии ещё + * лежит на складе: по нему сразу видно, что уже разошлось. + */ +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { getReceipt } 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", year: "numeric", + }).format(d); +} + +export default function ReceiptDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const query = useQuery({ + queryKey: ["wh-receipt", Number(id)], + queryFn: () => getReceipt(Number(id)), + staleTime: 60_000, + }); + + const r = query.data; + + return ( +
+
+
+ +

Приход

+ +
+
+ +
+ {query.isLoading &&
} + + {query.isError && ( +
+ Не удалось загрузить приход. +
+ )} + + {r && ( + <> +
+
{r.supplier_name || "Поставщик не указан"}
+
+ {r.doc_number ? `Накладная ${r.doc_number} · ` : ""} + {fmtDate(r.doc_date)} +
+ {r.posted_by_name && ( +
Провёл: {r.posted_by_name}
+ )} + {r.comment &&
{r.comment}
} +
+ + Позиций: {r.lines.length} + + + {fmtMoney(Number(r.total_amount))} ₽ + +
+
+ +
+
Состав
+
    + {r.lines.map((ln) => ( +
  • + {ln.thumb_url ? ( + + ) : ( +
    + )} +
    +
    {ln.part_name}
    +
    + {ln.article ? `арт. ${ln.article}` : "без артикула"} + {ln.place_code ? ` · ${ln.place_code}` : ""} +
    +
    + {ln.qty} × {fmtMoney(Number(ln.unit_price))} ₽ + {" · "} + +
    +
    +
    + {fmtMoney(Number(ln.amount))} ₽ +
    +
  • + ))} +
+
+ +

+ Приход уже проведён — правка и удаление только в CRM, чтобы не + разошлись остатки и журнал. +

+ + )} +
+
+ ); +} + +function RemainHint({ qty, remaining }: { qty: number; remaining: number }) { + if (remaining === qty) return лежит целиком; + if (remaining <= 0) return разошлось полностью; + return осталось {remaining}; +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx index 27b2fd0..281ad06 100644 --- a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingDraftPage.tsx @@ -10,7 +10,7 @@ */ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronLeft, Loader2, Plus, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -39,6 +39,7 @@ export default function ReceivingDraftPage() { const { id } = useParams<{ id: string }>(); const draftId = Number(id); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [draft, setDraft] = useState(null); const [loading, setLoading] = useState(true); @@ -92,7 +93,11 @@ export default function ReceivingDraftPage() { try { const r = await postDraft(draft.id, { place_id: placeId, doc_date: draft.order_date }); toast.success(`Приход проведён · позиций ${r.lines}`); - navigate("/wh/receiving", { replace: true }); + // Списки на входе в раздел кэшируются — после проведения они устарели. + await queryClient.invalidateQueries({ queryKey: ["wh-drafts"] }); + await queryClient.invalidateQueries({ queryKey: ["wh-receipts"] }); + // Сразу показываем, что получилось, — карточкой прихода. + navigate(`/wh/receipts/${r.receipt_id}`, { replace: true }); } catch (e) { toast.error((e as Error).message || "Не удалось провести приход"); setBusy(false); diff --git a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx index 141541a..f4b7f94 100644 --- a/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx +++ b/mechanic-pwa/frontend/src/pages/warehouse/ReceivingPage.tsx @@ -162,22 +162,26 @@ export default function ReceivingPage() { ) : (
    {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}` : ""} +
  • +
  • ))}