Compare commits
2
Commits
39176d2694
...
2a58c5ca94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a58c5ca94 | ||
|
|
064b02e735 |
@@ -19,6 +19,9 @@ 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 ReceiptDetailPage from "@/pages/warehouse/ReceiptDetailPage";
|
||||||
import StocktakePage from "@/pages/warehouse/StocktakePage";
|
import StocktakePage from "@/pages/warehouse/StocktakePage";
|
||||||
|
import TyresPage from "@/pages/warehouse/TyresPage";
|
||||||
|
import TyreSizesPage from "@/pages/warehouse/TyreSizesPage";
|
||||||
|
import TyreJournalPage from "@/pages/warehouse/TyreJournalPage";
|
||||||
import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage";
|
import StocktakeSessionPage from "@/pages/warehouse/StocktakeSessionPage";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
@@ -69,6 +72,10 @@ export default function App() {
|
|||||||
<Route path="/wh/receipts/:id" element={<RequireAdmin><ReceiptDetailPage /></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="/wh/tyres" element={<RequireAdmin><TyresPage /></RequireAdmin>} />
|
||||||
|
<Route path="/wh/tyres/sizes" element={<RequireAdmin><TyreSizesPage /></RequireAdmin>} />
|
||||||
|
<Route path="/wh/tyres/journal" element={<RequireAdmin><TyreJournalPage /></RequireAdmin>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -350,3 +350,137 @@ export async function applyStocktake(id: number): Promise<StocktakeDetail> {
|
|||||||
export async function deleteStocktake(id: number): Promise<{ ok: boolean }> {
|
export async function deleteStocktake(id: number): Promise<{ ok: boolean }> {
|
||||||
return api.delete(`wh/stocktakes/${id}`).json<{ ok: boolean }>();
|
return api.delete(`wh/stocktakes/${id}`).json<{ ok: boolean }>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Сезонная выдача резины ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Комплект живёт на машине от осени до следующей осени, поэтому выдача — не
|
||||||
|
// разовое списание, а документ со статусом. Закрыть предыдущий комплект
|
||||||
|
// (вернули / списали / не найден) обязательно, иначе сервер отвечает 409.
|
||||||
|
|
||||||
|
/** Три градации состояния. На сервере это «сколько сезонов осталось»: 2/1/0. */
|
||||||
|
export type TyreGrade = "good" | "season" | "worn";
|
||||||
|
|
||||||
|
export const TYRE_GRADE_LABEL: Record<TyreGrade, string> = {
|
||||||
|
good: "Хорошая",
|
||||||
|
season: "На сезон",
|
||||||
|
worn: "Под списание",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TyreStockRow {
|
||||||
|
part_id: number;
|
||||||
|
name: string;
|
||||||
|
size: string | null;
|
||||||
|
condition: string;
|
||||||
|
qty: number;
|
||||||
|
by_grade: Partial<Record<TyreGrade, number>>;
|
||||||
|
places: string[];
|
||||||
|
suggested: boolean;
|
||||||
|
/** Подтверждённая применимость к выбранной машине. */
|
||||||
|
fits: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TyreSet {
|
||||||
|
id: number;
|
||||||
|
car_id: number | null;
|
||||||
|
car_number: string | null;
|
||||||
|
driver_id: number | null;
|
||||||
|
driver_fio: string | null;
|
||||||
|
part_id: number;
|
||||||
|
part_name: string | null;
|
||||||
|
size: string | null;
|
||||||
|
season: string;
|
||||||
|
qty: number;
|
||||||
|
place_name: string | null;
|
||||||
|
issued_at: string;
|
||||||
|
issued_by: string | null;
|
||||||
|
grade_out: TyreGrade | null;
|
||||||
|
status: "on_car" | "returned" | "scrapped" | "lost";
|
||||||
|
closed_at: string | null;
|
||||||
|
closed_by: string | null;
|
||||||
|
grade_in: TyreGrade | null;
|
||||||
|
close_comment: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TyreContext {
|
||||||
|
car_id: number;
|
||||||
|
car_number: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
driver_id: number | null;
|
||||||
|
driver_fio: string | null;
|
||||||
|
suggested_size: string | null;
|
||||||
|
open_set: TyreSet | null;
|
||||||
|
stock: TyreStockRow[];
|
||||||
|
/** Известно ли, что подходит машине. false — фильтровать нечем, показываем всё. */
|
||||||
|
fit_known: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TyreClose {
|
||||||
|
action: "returned" | "scrapped" | "lost";
|
||||||
|
grade?: TyreGrade;
|
||||||
|
place_id?: number | null;
|
||||||
|
comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTyreContext(carId: number): Promise<TyreContext> {
|
||||||
|
return api.get(`wh/tyres/context?car_id=${carId}`).json<TyreContext>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueTyres(
|
||||||
|
body: {
|
||||||
|
car_id: number;
|
||||||
|
part_id: number;
|
||||||
|
qty: number;
|
||||||
|
grade: TyreGrade;
|
||||||
|
season?: "winter" | "summer";
|
||||||
|
place_id?: number | null;
|
||||||
|
comment?: string | null;
|
||||||
|
close?: TyreClose | null;
|
||||||
|
},
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<TyreSet> {
|
||||||
|
// Ключ идемпотентности обязателен: телефон у подъёмника теряет связь и
|
||||||
|
// ретраит, а повтор — это ещё четыре покрышки со склада.
|
||||||
|
return api
|
||||||
|
.post("wh/tyres/issue", { json: body, headers: { "Idempotency-Key": idempotencyKey } })
|
||||||
|
.json<TyreSet>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TyreSetsResponse {
|
||||||
|
items: TyreSet[];
|
||||||
|
total: number;
|
||||||
|
on_car: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTyreSets(params?: { status?: string; limit?: number }): Promise<TyreSetsResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set("status", params.status);
|
||||||
|
if (params?.limit) qs.set("limit", String(params.limit));
|
||||||
|
const s = qs.toString();
|
||||||
|
return api.get(`wh/tyres/sets${s ? `?${s}` : ""}`).json<TyreSetsResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TyreSizeRow {
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
tyre_size: string | null;
|
||||||
|
cars_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTyreSizes(): Promise<TyreSizeRow[]> {
|
||||||
|
return api.get("wh/tyres/sizes").json<TyreSizeRow[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTyreSize(body: {
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
tyre_size: string | null;
|
||||||
|
}): Promise<TyreSizeRow> {
|
||||||
|
return api.put("wh/tyres/sizes", { json: body }).json<TyreSizeRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вся резина в наличии — подсказка размеров в справочнике. */
|
||||||
|
export async function getTyreStock(): Promise<TyreStockRow[]> {
|
||||||
|
return api.get("wh/tyres/stock").json<TyreStockRow[]>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export default function ActionMenu() {
|
|||||||
emoji: "📋",
|
emoji: "📋",
|
||||||
onClick: () => navigate("/wh/stocktake"),
|
onClick: () => navigate("/wh/stocktake"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "tyres",
|
||||||
|
title: "Выдача резины",
|
||||||
|
description: "Сезонная переобувка: выдать комплект и закрыть старый",
|
||||||
|
emoji: "🛞",
|
||||||
|
onClick: () => navigate("/wh/tyres"),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Журнал выданных комплектов.
|
||||||
|
*
|
||||||
|
* Отвечает на два вопроса сезона: сколько машин уже переобуто и у кого
|
||||||
|
* комплект до сих пор на руках. Полноценный экран кампании (прогресс по парку,
|
||||||
|
* дефицит размеров) — следующим шагом в CRM, здесь нужен минимум, по которому
|
||||||
|
* кладовщик сверяется прямо у склада.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getTyreSets, TYRE_GRADE_LABEL, type TyreSet } from "@/api/warehouse";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<TyreSet["status"], string> = {
|
||||||
|
on_car: "На машине",
|
||||||
|
returned: "Вернули на склад",
|
||||||
|
scrapped: "Списан",
|
||||||
|
lost: "Не найден",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTERS: { key: string | undefined; label: string }[] = [
|
||||||
|
{ key: "on_car", label: "На машинах" },
|
||||||
|
{ key: undefined, label: "Все" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? iso
|
||||||
|
: new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TyreJournalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<string | undefined>("on_car");
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["tyre-sets", status],
|
||||||
|
queryFn: () => getTyreSets({ status, limit: 200 }),
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = q.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 gap-2 p-3">
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate("/wh/tyres")}>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">Выданные комплекты</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
на машинах: {q.data?.on_car ?? 0} · всего записей: {q.data?.total ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-3 pb-3">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.label}
|
||||||
|
size="sm"
|
||||||
|
variant={status === f.key ? "default" : "outline"}
|
||||||
|
onClick={() => setStatus(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 divide-y">
|
||||||
|
{q.isLoading && <p className="p-4 text-sm text-muted-foreground">Загрузка…</p>}
|
||||||
|
{!q.isLoading && items.length === 0 && (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Пока ничего не выдавали.</p>
|
||||||
|
)}
|
||||||
|
{items.map((s) => (
|
||||||
|
<div key={s.id} className="p-3">
|
||||||
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-semibold">{s.car_number ?? "—"}</span>
|
||||||
|
<span className="text-sm">{s.size ?? s.part_name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{s.qty} шт</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs rounded px-1.5 py-0.5 ${
|
||||||
|
s.status === "on_car"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[s.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{fmtDate(s.issued_at)}
|
||||||
|
{s.grade_out ? ` · ${TYRE_GRADE_LABEL[s.grade_out].toLowerCase()}` : ""}
|
||||||
|
{s.driver_fio ? ` · ${s.driver_fio}` : ""}
|
||||||
|
{s.issued_by ? ` · выдал ${s.issued_by}` : ""}
|
||||||
|
</p>
|
||||||
|
{s.status !== "on_car" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
закрыт {fmtDate(s.closed_at)}
|
||||||
|
{s.grade_in ? ` · вернули как «${TYRE_GRADE_LABEL[s.grade_in].toLowerCase()}»` : ""}
|
||||||
|
{s.close_comment ? ` · ${s.close_comment}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Штатные размеры резины по моделям парка.
|
||||||
|
*
|
||||||
|
* Заполняется один раз перед сезоном: без размера выдача не подсказывает, что
|
||||||
|
* брать, и кладовщик ищет позицию в номенклатуре руками — на четырёхстах
|
||||||
|
* машинах это заметно. Модели идут по числу машин, чтобы самые ходовые
|
||||||
|
* заполнились первыми и дальше можно было не продолжать.
|
||||||
|
*
|
||||||
|
* В поле — подсказка из тех размеров, что реально лежат на складе: набирать
|
||||||
|
* «225/65/17» с телефона долго и легко ошибиться.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronLeft } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
apiErrorText,
|
||||||
|
getTyreSizes,
|
||||||
|
getTyreStock,
|
||||||
|
setTyreSize,
|
||||||
|
type TyreSizeRow,
|
||||||
|
} from "@/api/warehouse";
|
||||||
|
|
||||||
|
export default function TyreSizesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sizesQuery = useQuery({ queryKey: ["tyre-sizes"], queryFn: getTyreSizes, staleTime: 30_000 });
|
||||||
|
const stockQuery = useQuery({ queryKey: ["tyre-stock"], queryFn: getTyreStock, staleTime: 300_000 });
|
||||||
|
|
||||||
|
const rows = sizesQuery.data ?? [];
|
||||||
|
const filled = useMemo(() => rows.filter((r) => r.tyre_size).length, [rows]);
|
||||||
|
const known = useMemo(
|
||||||
|
() => Array.from(new Set((stockQuery.data ?? []).map((s) => s.size).filter(Boolean))) as string[],
|
||||||
|
[stockQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async (row: TyreSizeRow, value: string | null) => {
|
||||||
|
const key = `${row.brand}|${row.model}`;
|
||||||
|
setSavingKey(key);
|
||||||
|
try {
|
||||||
|
await setTyreSize({ brand: row.brand ?? "", model: row.model ?? "", tyre_size: value });
|
||||||
|
await qc.invalidateQueries({ queryKey: ["tyre-sizes"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(await apiErrorText(e, "Не удалось сохранить размер"));
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 gap-2 p-3">
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate("/wh/tyres")}>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">Штатные размеры</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
заполнено {filled} из {rows.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<datalist id="tyre-sizes-known">
|
||||||
|
{known.map((s) => (
|
||||||
|
<option key={s} value={s} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<main className="flex-1 divide-y">
|
||||||
|
{sizesQuery.isLoading && <p className="p-4 text-sm text-muted-foreground">Загрузка…</p>}
|
||||||
|
{rows.map((r) => {
|
||||||
|
const key = `${r.brand}|${r.model}`;
|
||||||
|
return <SizeRow key={key} row={r} saving={savingKey === key} onSave={(v) => save(r, v)} />;
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SizeRow({
|
||||||
|
row,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
row: TyreSizeRow;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: (value: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(row.tyre_size ?? "");
|
||||||
|
const dirty = (draft.trim() || null) !== (row.tyre_size ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{[row.brand, row.model].filter(Boolean).join(" ") || "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.cars_count} авто</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
list="tyre-sizes-known"
|
||||||
|
placeholder="225/65/17"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-input bg-background px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={dirty ? "default" : "ghost"}
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
onClick={() => onSave(draft.trim() || null)}
|
||||||
|
>
|
||||||
|
{saving ? "…" : <Check className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Выдача резины — экран кладовщика у подъёмника.
|
||||||
|
*
|
||||||
|
* Осенью через него проходит весь парк, поэтому всё построено вокруг скорости:
|
||||||
|
* выбрал машину → приложение само подсказало штатный размер и остаток → три
|
||||||
|
* кнопки состояния → выдал. Один запрос за контекстом вместо трёх — телефон у
|
||||||
|
* подъёмника работает по слабой связи.
|
||||||
|
*
|
||||||
|
* Главное правило: пока не сказано, что случилось с предыдущим комплектом,
|
||||||
|
* новый не выдаётся. Сервер это тоже проверяет (409), здесь просто не даём
|
||||||
|
* дойти до отказа.
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronLeft, Circle, History, Ruler, Search } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { searchCars, type CarBrief } from "@/api/repairsCreate";
|
||||||
|
import {
|
||||||
|
apiErrorText,
|
||||||
|
getTyreContext,
|
||||||
|
issueTyres,
|
||||||
|
TYRE_GRADE_LABEL,
|
||||||
|
type TyreClose,
|
||||||
|
type TyreContext,
|
||||||
|
type TyreGrade,
|
||||||
|
type TyreStockRow,
|
||||||
|
} from "@/api/warehouse";
|
||||||
|
|
||||||
|
const GRADES: TyreGrade[] = ["good", "season", "worn"];
|
||||||
|
|
||||||
|
const CLOSE_LABEL: Record<TyreClose["action"], string> = {
|
||||||
|
returned: "Вернули на склад",
|
||||||
|
scrapped: "Списали",
|
||||||
|
lost: "Не найден",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? iso
|
||||||
|
: new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько дней комплект отходил — по этому видно, доездил он сезон или месяц. */
|
||||||
|
function daysOn(iso: string): number {
|
||||||
|
const d = new Date(iso).getTime();
|
||||||
|
return Number.isNaN(d) ? 0 : Math.max(0, Math.round((Date.now() - d) / 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TyresPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sp, setSp] = useSearchParams();
|
||||||
|
const carId = sp.get("car") ? Number(sp.get("car")) : null;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-2"
|
||||||
|
onClick={() => (carId ? setSp({}, { replace: true }) : navigate("/"))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-base font-semibold">Выдача резины</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate("/wh/tyres/sizes")}>
|
||||||
|
<Ruler className="h-4 w-4 mr-1" />
|
||||||
|
Размеры
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate("/wh/tyres/journal")}>
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{carId ? (
|
||||||
|
<IssueForm carId={carId} onDone={() => setSp({}, { replace: true })} />
|
||||||
|
) : (
|
||||||
|
<CarPicker onPick={(id) => setSp({ car: String(id) }, { replace: true })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Выбор машины ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CarPicker({ onPick }: { onPick: (carId: number) => void }) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const list = await searchCars(q);
|
||||||
|
if (!cancelled) setCars(list);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCars([]);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-3 border-b">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Госномер…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 divide-y">
|
||||||
|
{loading && <p className="p-4 text-sm text-muted-foreground">Загрузка…</p>}
|
||||||
|
{!loading && cars.length === 0 && (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Ничего не нашлось</p>
|
||||||
|
)}
|
||||||
|
{cars.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onPick(c.id)}
|
||||||
|
className="w-full text-left p-3 active:bg-muted flex items-baseline gap-3"
|
||||||
|
>
|
||||||
|
<span className="font-mono font-semibold">{c.plate ?? "—"}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{[c.brand, c.model].filter(Boolean).join(" ")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Выдача ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||||
|
const ctxQuery = useQuery({
|
||||||
|
queryKey: ["tyre-ctx", carId],
|
||||||
|
queryFn: () => getTyreContext(carId),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
const ctx: TyreContext | undefined = ctxQuery.data;
|
||||||
|
|
||||||
|
const [partId, setPartId] = useState<number | null>(null);
|
||||||
|
const [qty, setQty] = useState(4);
|
||||||
|
const [grade, setGrade] = useState<TyreGrade>("good");
|
||||||
|
const [closeAction, setCloseAction] = useState<TyreClose["action"] | null>(null);
|
||||||
|
const [closeGrade, setCloseGrade] = useState<TyreGrade>("season");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
// Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи
|
||||||
|
// должен попасть в ТУ ЖЕ выдачу, а не создать вторую.
|
||||||
|
const [idemKey] = useState(() => crypto.randomUUID());
|
||||||
|
|
||||||
|
// Подставляем подсказанный размер, как только пришёл контекст.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctx || partId !== null) return;
|
||||||
|
const best = ctx.stock.find((s) => (s.fits || s.suggested) && s.qty > 0);
|
||||||
|
if (best) setPartId(best.part_id);
|
||||||
|
}, [ctx, partId]);
|
||||||
|
|
||||||
|
// По умолчанию показываем ТОЛЬКО подходящее этой машине — иначе кладовщик
|
||||||
|
// выбирает размер из девятнадцати позиций глазами. Если применимость по
|
||||||
|
// машине не заполнена, фильтровать нечем: прятать правильную резину по
|
||||||
|
// незнанию хуже, чем показать лишнюю (то же правило, что у подбора запчастей).
|
||||||
|
const fitting = useMemo(
|
||||||
|
() => (ctx ? ctx.stock.filter((s) => s.fits || s.suggested) : []),
|
||||||
|
[ctx],
|
||||||
|
);
|
||||||
|
const visibleStock = showAll || !ctx?.fit_known ? (ctx?.stock ?? []) : fitting;
|
||||||
|
|
||||||
|
if (ctxQuery.isLoading) {
|
||||||
|
return <main className="p-4 text-sm text-muted-foreground">Загрузка…</main>;
|
||||||
|
}
|
||||||
|
if (!ctx) {
|
||||||
|
return <main className="p-4 text-sm text-destructive">Не удалось загрузить машину</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSet = ctx.open_set;
|
||||||
|
const needClose = openSet !== null && closeAction === null;
|
||||||
|
const chosen = ctx.stock.find((s) => s.part_id === partId) ?? null;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (partId === null) {
|
||||||
|
toast.error("Выберите размер");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await issueTyres(
|
||||||
|
{
|
||||||
|
car_id: carId,
|
||||||
|
part_id: partId,
|
||||||
|
qty,
|
||||||
|
grade,
|
||||||
|
comment: comment.trim() || null,
|
||||||
|
close: closeAction
|
||||||
|
? {
|
||||||
|
action: closeAction,
|
||||||
|
grade: closeAction === "returned" ? closeGrade : undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
idemKey,
|
||||||
|
);
|
||||||
|
toast.success(`Выдано ${qty} шт · ${ctx.car_number ?? ""}`.trim());
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(await apiErrorText(e, "Не удалось выдать комплект"));
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 pb-28">
|
||||||
|
{/* Машина и арендатор */}
|
||||||
|
<section className="p-3 border-b">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="font-mono text-lg font-semibold">{ctx.car_number ?? "—"}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{ctx.driver_fio ? `Арендатор: ${ctx.driver_fio}` : "Арендатор не определён"}
|
||||||
|
</p>
|
||||||
|
{ctx.suggested_size ? (
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
Штатный размер: <span className="font-semibold">{ctx.suggested_size}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Штатный размер модели не задан — укажите его в разделе «Размеры», и подсказка появится.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Предыдущий комплект — закрыть обязательно */}
|
||||||
|
{openSet && (
|
||||||
|
<section className="p-3 border-b bg-amber-50 dark:bg-amber-950/30">
|
||||||
|
<h2 className="text-sm font-semibold">На машине уже есть комплект</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{openSet.size ?? openSet.part_name} · {openSet.qty} шт · выдан {fmtDate(openSet.issued_at)}
|
||||||
|
{` (${daysOn(openSet.issued_at)} дн.)`}
|
||||||
|
{openSet.grade_out ? ` · при выдаче ${TYRE_GRADE_LABEL[openSet.grade_out].toLowerCase()}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-2 mb-1">Что с ним?</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(Object.keys(CLOSE_LABEL) as TyreClose["action"][]).map((a) => (
|
||||||
|
<Button
|
||||||
|
key={a}
|
||||||
|
variant={closeAction === a ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCloseAction(a)}
|
||||||
|
>
|
||||||
|
{CLOSE_LABEL[a]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{closeAction === "returned" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm mb-1">Состояние снятой резины</p>
|
||||||
|
<GradePicker value={closeGrade} onChange={setCloseGrade} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Вернётся на склад отдельной партией с этой оценкой.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Что выдаём */}
|
||||||
|
<section className="p-3 border-b">
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-sm font-semibold">Что выдаём</h2>
|
||||||
|
{ctx.fit_known && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{showAll ? `все размеры (${ctx.stock.length})` : `подходит: ${fitting.length}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!ctx.fit_known && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Для этой модели не отмечено, какая резина подходит, — показаны все размеры.
|
||||||
|
Отметить можно в карточке позиции на складе.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{visibleStock.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">На складе нет резины.</p>
|
||||||
|
)}
|
||||||
|
{visibleStock.map((s) => (
|
||||||
|
<StockRow
|
||||||
|
key={s.part_id}
|
||||||
|
row={s}
|
||||||
|
selected={partId === s.part_id}
|
||||||
|
onSelect={() => setPartId(s.part_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{ctx.fit_known && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
>
|
||||||
|
{showAll
|
||||||
|
? `Только подходящие (${fitting.length})`
|
||||||
|
: `Показать все размеры (${ctx.stock.length})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{chosen && chosen.qty < qty && (
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-500 mt-2">
|
||||||
|
На складе {chosen.qty} шт — склад уйдёт в минус на {qty - chosen.qty}. Выдать можно.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Количество и состояние */}
|
||||||
|
<section className="p-3 border-b">
|
||||||
|
<p className="text-sm mb-1">Сколько покрышек</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[2, 4, 5].map((n) => (
|
||||||
|
<Button
|
||||||
|
key={n}
|
||||||
|
variant={qty === n ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setQty(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-3 mb-1">Состояние выдаваемой резины</p>
|
||||||
|
<GradePicker value={grade} onChange={setGrade} />
|
||||||
|
<Input
|
||||||
|
className="mt-3"
|
||||||
|
placeholder="Комментарий (необязательно)"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Кнопка выдачи */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background p-3">
|
||||||
|
<Button className="w-full" disabled={saving || needClose || partId === null} onClick={submit}>
|
||||||
|
{saving ? "Выдаём…" : needClose ? "Сначала закройте предыдущий комплект" : "Выдать комплект"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: TyreGrade;
|
||||||
|
onChange: (g: TyreGrade) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{GRADES.map((g) => (
|
||||||
|
<Button
|
||||||
|
key={g}
|
||||||
|
variant={value === g ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(g)}
|
||||||
|
>
|
||||||
|
{TYRE_GRADE_LABEL[g]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StockRow({
|
||||||
|
row,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
row: TyreStockRow;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const grades = (Object.keys(TYRE_GRADE_LABEL) as TyreGrade[])
|
||||||
|
.filter((g) => (row.by_grade[g] ?? 0) > 0)
|
||||||
|
.map((g) => `${TYRE_GRADE_LABEL[g].toLowerCase()}: ${row.by_grade[g]}`);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`w-full text-left rounded-md border p-2.5 flex items-start gap-2 ${
|
||||||
|
selected ? "border-primary bg-primary/5" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
className={`h-4 w-4 mt-0.5 shrink-0 ${selected ? "fill-primary text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold">{row.size ?? row.name}</span>
|
||||||
|
{(row.fits || row.suggested) && (
|
||||||
|
<span className="text-xs rounded px-1.5 py-0.5 bg-primary/10 text-primary">
|
||||||
|
{row.fits ? "подходит" : "штатный"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${row.qty > 0 ? "text-muted-foreground" : "text-destructive"}`}>
|
||||||
|
{row.qty} шт
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground truncate">
|
||||||
|
{[grades.join(" · "), row.places.join(", ")].filter(Boolean).join(" — ") || row.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user