Compare commits

..
2 Commits
Author SHA1 Message Date
tremble7681 2a58c5ca94 feat(tyres): по умолчанию только подходящая резина, «Показать все» разворачивает
Кладовщик у подъёмника выбирал размер из девятнадцати позиций глазами. Теперь
экран показывает только подходящее выбранной машине (по подтверждённой
применимости), с счётчиком и кнопкой-переключателем на полный список.

Если по модели применимость не отмечена, фильтровать нечем — показываются все
размеры и объясняется, почему: прятать правильную резину по незнанию хуже, чем
показать лишнюю.

Плашка на строке различает «подходит» (применимость) и «штатный» (справочник
размеров), и предвыбор позиции теперь учитывает оба признака.
2026-08-13 20:10:39 +10:00
tremble7681 064b02e735 feat(tyres): экран сезонной выдачи резины, справочник размеров и журнал
Осенью через кладовщика проходит весь парк, поэтому экран построен вокруг
скорости: выбрал машину — приложение подсказало штатный размер модели, показало
остаток и контейнер, три кнопки состояния, выдал. Контекст приходит одним
запросом: телефон у подъёмника работает по слабой связи, и три round-trip'а на
каждую машину заметны на четырёхстах машинах.

Пока не сказано, что случилось с предыдущим комплектом (вернули / списали / не
найден), кнопка выдачи заблокирована. Сервер это тоже проверяет — здесь просто
не даём дойти до отказа.

Ключ идемпотентности живёт, пока открыта форма: ретрай после обрыва связи должен
попасть в ту же выдачу, а не создать вторую с четырьмя лишними покрышками.

Справочник размеров подсказывает те размеры, что реально лежат на складе:
набирать «225/65/17» с телефона долго и легко ошибиться. Модели отсортированы по
числу машин — самые ходовые заполняются первыми.

Разделы админские, как приёмка и инвентаризация.
2026-08-13 08:41:28 +10:00
6 changed files with 830 additions and 0 deletions
+7
View File
@@ -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>
);
+134
View File
@@ -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>
);
}