diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 2ad847f..1c75fc9 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -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() { } /> } /> } /> + {/* Сезонная выдача резины — тот же круг, что приёмка и инвентаризация. */} + } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/warehouse.ts b/mechanic-pwa/frontend/src/api/warehouse.ts index b9186d6..74a51a2 100644 --- a/mechanic-pwa/frontend/src/api/warehouse.ts +++ b/mechanic-pwa/frontend/src/api/warehouse.ts @@ -350,3 +350,133 @@ export async function applyStocktake(id: number): Promise { 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 = { + good: "Хорошая", + season: "На сезон", + worn: "Под списание", +}; + +export interface TyreStockRow { + part_id: number; + name: string; + size: string | null; + condition: string; + qty: number; + by_grade: Partial>; + places: string[]; + suggested: 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[]; +} + +export interface TyreClose { + action: "returned" | "scrapped" | "lost"; + grade?: TyreGrade; + place_id?: number | null; + comment?: string | null; +} + +export async function getTyreContext(carId: number): Promise { + return api.get(`wh/tyres/context?car_id=${carId}`).json(); +} + +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 { + // Ключ идемпотентности обязателен: телефон у подъёмника теряет связь и + // ретраит, а повтор — это ещё четыре покрышки со склада. + return api + .post("wh/tyres/issue", { json: body, headers: { "Idempotency-Key": idempotencyKey } }) + .json(); +} + +export interface TyreSetsResponse { + items: TyreSet[]; + total: number; + on_car: number; +} + +export async function getTyreSets(params?: { status?: string; limit?: number }): Promise { + 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(); +} + +export interface TyreSizeRow { + brand: string | null; + model: string | null; + tyre_size: string | null; + cars_count: number; +} + +export async function getTyreSizes(): Promise { + return api.get("wh/tyres/sizes").json(); +} + +export async function setTyreSize(body: { + brand: string; + model: string; + tyre_size: string | null; +}): Promise { + return api.put("wh/tyres/sizes", { json: body }).json(); +} + +/** Вся резина в наличии — подсказка размеров в справочнике. */ +export async function getTyreStock(): Promise { + return api.get("wh/tyres/stock").json(); +} diff --git a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx index da2db62..efe6519 100644 --- a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx +++ b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx @@ -52,6 +52,13 @@ export default function ActionMenu() { emoji: "📋", onClick: () => navigate("/wh/stocktake"), }, + { + key: "tyres", + title: "Выдача резины", + description: "Сезонная переобувка: выдать комплект и закрыть старый", + emoji: "🛞", + onClick: () => navigate("/wh/tyres"), + }, ] : []), ]; diff --git a/mechanic-pwa/frontend/src/pages/warehouse/TyreJournalPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/TyreJournalPage.tsx new file mode 100644 index 0000000..b074881 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/TyreJournalPage.tsx @@ -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 = { + 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("on_car"); + + const q = useQuery({ + queryKey: ["tyre-sets", status], + queryFn: () => getTyreSets({ status, limit: 200 }), + staleTime: 15_000, + }); + + const items = q.data?.items ?? []; + + return ( +
+
+
+ +
+

Выданные комплекты

+

+ на машинах: {q.data?.on_car ?? 0} · всего записей: {q.data?.total ?? 0} +

+
+
+
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+ {q.isLoading &&

Загрузка…

} + {!q.isLoading && items.length === 0 && ( +

Пока ничего не выдавали.

+ )} + {items.map((s) => ( +
+
+ {s.car_number ?? "—"} + {s.size ?? s.part_name} + {s.qty} шт + + {STATUS_LABEL[s.status]} + +
+

+ {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}` : ""} +

+ {s.status !== "on_car" && ( +

+ закрыт {fmtDate(s.closed_at)} + {s.grade_in ? ` · вернули как «${TYRE_GRADE_LABEL[s.grade_in].toLowerCase()}»` : ""} + {s.close_comment ? ` · ${s.close_comment}` : ""} +

+ )} +
+ ))} +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/TyreSizesPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/TyreSizesPage.tsx new file mode 100644 index 0000000..6cbd2a2 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/TyreSizesPage.tsx @@ -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(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 ( +
+
+
+ +
+

Штатные размеры

+

+ заполнено {filled} из {rows.length} +

+
+
+
+ + + {known.map((s) => ( + + +
+ {sizesQuery.isLoading &&

Загрузка…

} + {rows.map((r) => { + const key = `${r.brand}|${r.model}`; + return save(r, v)} />; + })} +
+
+ ); +} + +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 ( +
+
+

+ {[row.brand, row.model].filter(Boolean).join(" ") || "—"} +

+

{row.cars_count} авто

+
+ setDraft(e.target.value)} + className="w-32 rounded-md border border-input bg-background px-2 py-1.5 text-sm" + /> + +
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/warehouse/TyresPage.tsx b/mechanic-pwa/frontend/src/pages/warehouse/TyresPage.tsx new file mode 100644 index 0000000..194cbca --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/warehouse/TyresPage.tsx @@ -0,0 +1,416 @@ +/** + * Выдача резины — экран кладовщика у подъёмника. + * + * Осенью через него проходит весь парк, поэтому всё построено вокруг скорости: + * выбрал машину → приложение само подсказало штатный размер и остаток → три + * кнопки состояния → выдал. Один запрос за контекстом вместо трёх — телефон у + * подъёмника работает по слабой связи. + * + * Главное правило: пока не сказано, что случилось с предыдущим комплектом, + * новый не выдаётся. Сервер это тоже проверяет (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 = { + 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 ( +
+
+
+
+ +

Выдача резины

+
+
+ + +
+
+
+ + {carId ? ( + setSp({}, { replace: true })} /> + ) : ( + setSp({ car: String(id) }, { replace: true })} /> + )} +
+ ); +} + +// ── Выбор машины ─────────────────────────────────────────────────────────── + +function CarPicker({ onPick }: { onPick: (carId: number) => void }) { + const [q, setQ] = useState(""); + const [cars, setCars] = useState([]); + 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 ( + <> +
+
+ + setQ(e.target.value)} + className="pl-8" + /> +
+
+
+ {loading &&

Загрузка…

} + {!loading && cars.length === 0 && ( +

Ничего не нашлось

+ )} + {cars.map((c) => ( + + ))} +
+ + ); +} + +// ── Выдача ───────────────────────────────────────────────────────────────── + +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(null); + const [qty, setQty] = useState(4); + const [grade, setGrade] = useState("good"); + const [closeAction, setCloseAction] = useState(null); + const [closeGrade, setCloseGrade] = useState("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 suggested = ctx.stock.find((s) => s.suggested && s.qty > 0); + if (suggested) setPartId(suggested.part_id); + }, [ctx, partId]); + + const visibleStock = useMemo(() => { + if (!ctx) return []; + return showAll ? ctx.stock : ctx.stock.slice(0, 6); + }, [ctx, showAll]); + + if (ctxQuery.isLoading) { + return
Загрузка…
; + } + if (!ctx) { + return
Не удалось загрузить машину
; + } + + 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 ( +
+ {/* Машина и арендатор */} +
+
+ {ctx.car_number ?? "—"} + + {[ctx.brand, ctx.model].filter(Boolean).join(" ")} + +
+

+ {ctx.driver_fio ? `Арендатор: ${ctx.driver_fio}` : "Арендатор не определён"} +

+ {ctx.suggested_size ? ( +

+ Штатный размер: {ctx.suggested_size} +

+ ) : ( +

+ Штатный размер модели не задан — укажите его в разделе «Размеры», и подсказка появится. +

+ )} +
+ + {/* Предыдущий комплект — закрыть обязательно */} + {openSet && ( +
+

На машине уже есть комплект

+

+ {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()}` : ""} +

+

Что с ним?

+
+ {(Object.keys(CLOSE_LABEL) as TyreClose["action"][]).map((a) => ( + + ))} +
+ {closeAction === "returned" && ( +
+

Состояние снятой резины

+ +

+ Вернётся на склад отдельной партией с этой оценкой. +

+
+ )} +
+ )} + + {/* Что выдаём */} +
+

Что выдаём

+
+ {visibleStock.length === 0 && ( +

На складе нет резины.

+ )} + {visibleStock.map((s) => ( + setPartId(s.part_id)} + /> + ))} +
+ {!showAll && ctx.stock.length > 6 && ( + + )} + {chosen && chosen.qty < qty && ( +

+ На складе {chosen.qty} шт — склад уйдёт в минус на {qty - chosen.qty}. Выдать можно. +

+ )} +
+ + {/* Количество и состояние */} +
+

Сколько покрышек

+
+ {[2, 4, 5].map((n) => ( + + ))} +
+

Состояние выдаваемой резины

+ + setComment(e.target.value)} + /> +
+ + {/* Кнопка выдачи */} +
+ +
+
+ ); +} + +function GradePicker({ + value, + onChange, +}: { + value: TyreGrade; + onChange: (g: TyreGrade) => void; +}) { + return ( +
+ {GRADES.map((g) => ( + + ))} +
+ ); +} + +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 ( + + ); +}