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 ReceiptDetailPage from "@/pages/warehouse/ReceiptDetailPage";
|
||||
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";
|
||||
|
||||
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/stocktake" element={<RequireAdmin><StocktakePage /></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 />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -350,3 +350,137 @@ export async function applyStocktake(id: number): Promise<StocktakeDetail> {
|
||||
export async function deleteStocktake(id: number): Promise<{ 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: "📋",
|
||||
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