wh: карточка проведённого прихода в разделе «Приёмка» (чтение)

Строка в «Последних приходах» стала кликабельной, после проведения
накладной открывается сразу она — видно, что именно получилось.

Показываем снимки на момент приёмки (имя, артикул), а не текущие значения
каталога: накладная — исторический документ, переименование номенклатуры
не должно её переписывать. Единственное «сегодняшнее» поле — сколько от
партии ещё лежит на складе: лежит целиком / осталось N / разошлось.

Правки нет намеренно: проведённый приход уже стал партиями и движениями,
и «поправить» его значило бы разъехаться с журналом — сторно делается в
CRM, где видны последствия. В карточке это сказано текстом, чтобы не
выглядело как недоделка.

Заодно после проведения инвалидируются списки черновиков и приходов —
иначе при возврате в раздел они показывались из кэша устаревшими.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:29:39 +10:00
co-authored by Claude Opus 4.8
parent 26c0261784
commit 4c1df109ca
5 changed files with 188 additions and 17 deletions
+2
View File
@@ -17,6 +17,7 @@ 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 ReceivingPage from "@/pages/warehouse/ReceivingPage";
import ReceivingDraftPage from "@/pages/warehouse/ReceivingDraftPage"; import ReceivingDraftPage from "@/pages/warehouse/ReceivingDraftPage";
import ReceiptDetailPage from "@/pages/warehouse/ReceiptDetailPage";
import StocktakePage from "@/pages/warehouse/StocktakePage"; import StocktakePage from "@/pages/warehouse/StocktakePage";
import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage"; import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage";
@@ -65,6 +66,7 @@ export default function App() {
{/* Склад — приёмка и инвентаризация с телефона (пока только админы). */} {/* Склад — приёмка и инвентаризация с телефона (пока только админы). */}
<Route path="/wh/receiving" element={<RequireAdmin><ReceivingPage /></RequireAdmin>} /> <Route path="/wh/receiving" element={<RequireAdmin><ReceivingPage /></RequireAdmin>} />
<Route path="/wh/receiving/:id" element={<RequireAdmin><ReceivingDraftPage /></RequireAdmin>} /> <Route path="/wh/receiving/:id" element={<RequireAdmin><ReceivingDraftPage /></RequireAdmin>} />
<Route path="/wh/receipts/:id" element={<RequireAdmin><ReceiptDetailPage /></RequireAdmin>} />
<Route path="/wh/stocktake" element={<RequireAdmin><StocktakePage /></RequireAdmin>} /> <Route path="/wh/stocktake" element={<RequireAdmin><StocktakePage /></RequireAdmin>} />
<Route path="/wh/stocktake/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} /> <Route path="/wh/stocktake/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
@@ -165,6 +165,32 @@ export async function getReceipts(limit = 30): Promise<ReceiptListResponse> {
return api.get(`wh/receipts?limit=${limit}`).json<ReceiptListResponse>(); return api.get(`wh/receipts?limit=${limit}`).json<ReceiptListResponse>();
} }
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<ReceiptDetail> {
return api.get(`wh/receipts/${id}`).json<ReceiptDetail>();
}
// ── Инвентаризация ───────────────────────────────────────────────────────── // ── Инвентаризация ─────────────────────────────────────────────────────────
export interface Stocktake { export interface Stocktake {
@@ -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 (
<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("/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-4">
{query.isLoading && <div className="h-24 rounded-lg bg-muted animate-pulse" />}
{query.isError && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
Не удалось загрузить приход.
</div>
)}
{r && (
<>
<section className="rounded-lg border bg-card p-3 space-y-1">
<div className="text-base font-semibold">{r.supplier_name || "Поставщик не указан"}</div>
<div className="text-sm text-muted-foreground">
{r.doc_number ? `Накладная ${r.doc_number} · ` : ""}
{fmtDate(r.doc_date)}
</div>
{r.posted_by_name && (
<div className="text-xs text-muted-foreground">Провёл: {r.posted_by_name}</div>
)}
{r.comment && <div className="text-xs text-muted-foreground italic">{r.comment}</div>}
<div className="pt-2 flex items-baseline justify-between border-t mt-2">
<span className="text-sm text-muted-foreground">
Позиций: {r.lines.length}
</span>
<span className="text-lg font-semibold tabular-nums">
{fmtMoney(Number(r.total_amount))}
</span>
</div>
</section>
<section className="rounded-lg border bg-card overflow-hidden">
<div className="px-3 py-2 border-b bg-muted/40 text-sm font-semibold">Состав</div>
<ul className="divide-y">
{r.lines.map((ln) => (
<li key={ln.batch_id} className="p-3 flex items-start gap-3">
{ln.thumb_url ? (
<img
src={ln.thumb_url}
alt=""
loading="lazy"
className="h-10 w-10 rounded border object-cover flex-shrink-0"
/>
) : (
<div className="h-10 w-10 rounded border bg-muted flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{ln.part_name}</div>
<div className="text-xs text-muted-foreground truncate">
{ln.article ? `арт. ${ln.article}` : "без артикула"}
{ln.place_code ? ` · ${ln.place_code}` : ""}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{ln.qty} × {fmtMoney(Number(ln.unit_price))}
{" · "}
<RemainHint qty={ln.qty} remaining={ln.qty_remaining} />
</div>
</div>
<div className="text-sm font-medium whitespace-nowrap tabular-nums">
{fmtMoney(Number(ln.amount))}
</div>
</li>
))}
</ul>
</section>
<p className="text-xs text-muted-foreground px-1">
Приход уже проведён правка и удаление только в CRM, чтобы не
разошлись остатки и журнал.
</p>
</>
)}
</main>
</div>
);
}
function RemainHint({ qty, remaining }: { qty: number; remaining: number }) {
if (remaining === qty) return <span>лежит целиком</span>;
if (remaining <= 0) return <span>разошлось полностью</span>;
return <span>осталось {remaining}</span>;
}
@@ -10,7 +10,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; 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 { ChevronLeft, Loader2, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -39,6 +39,7 @@ export default function ReceivingDraftPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const draftId = Number(id); const draftId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -92,7 +93,11 @@ export default function ReceivingDraftPage() {
try { try {
const r = await postDraft(draft.id, { place_id: placeId, doc_date: draft.order_date }); const r = await postDraft(draft.id, { place_id: placeId, doc_date: draft.order_date });
toast.success(`Приход проведён · позиций ${r.lines}`); 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) { } catch (e) {
toast.error((e as Error).message || "Не удалось провести приход"); toast.error((e as Error).message || "Не удалось провести приход");
setBusy(false); setBusy(false);
@@ -162,9 +162,11 @@ export default function ReceivingPage() {
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{receipts.map((r) => ( {receipts.map((r) => (
<li <li key={r.id}>
key={r.id} <button
className="rounded-lg border bg-card p-3 flex items-center gap-3" type="button"
onClick={() => navigate(`/wh/receipts/${r.id}`)}
className="w-full rounded-lg border bg-card p-3 text-left hover:bg-accent/40 flex items-center gap-3"
> >
<PackageCheck className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <PackageCheck className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -178,6 +180,8 @@ export default function ReceivingPage() {
<div className="text-sm font-medium whitespace-nowrap"> <div className="text-sm font-medium whitespace-nowrap">
{fmtMoney(Number(r.total_amount))} {fmtMoney(Number(r.total_amount))}
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
</li> </li>
))} ))}
</ul> </ul>