wh: карточка проведённого прихода в разделе «Приёмка» (чтение)
Строка в «Последних приходах» стала кликабельной, после проведения накладной открывается сразу она — видно, что именно получилось. Показываем снимки на момент приёмки (имя, артикул), а не текущие значения каталога: накладная — исторический документ, переименование номенклатуры не должно её переписывать. Единственное «сегодняшнее» поле — сколько от партии ещё лежит на складе: лежит целиком / осталось N / разошлось. Правки нет намеренно: проведённый приход уже стал партиями и движениями, и «поправить» его значило бы разъехаться с журналом — сторно делается в CRM, где видны последствия. В карточке это сказано текстом, чтобы не выглядело как недоделка. Заодно после проведения инвалидируются списки черновиков и приходов — иначе при возврате в раздел они показывались из кэша устаревшими. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
{/* Склад — приёмка и инвентаризация с телефона (пока только админы). */}
|
||||
<Route path="/wh/receiving" element={<RequireAdmin><ReceivingPage /></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/:id" element={<RequireAdmin><StocktakeSessionPage /></RequireAdmin>} />
|
||||
<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>();
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 { 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<Draft | null>(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);
|
||||
|
||||
@@ -162,22 +162,26 @@ export default function ReceivingPage() {
|
||||
) : (
|
||||
<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}` : ""}
|
||||
<li key={r.id}>
|
||||
<button
|
||||
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" />
|
||||
<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>
|
||||
<div className="text-sm font-medium whitespace-nowrap">
|
||||
{fmtMoney(Number(r.total_amount))} ₽
|
||||
</div>
|
||||
<div className="text-sm font-medium whitespace-nowrap">
|
||||
{fmtMoney(Number(r.total_amount))} ₽
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user