feat(tyres): на машине с комплектом — выбор «доложить» или «заменить»

Раньше экран требовал закрыть старый комплект (вернули / списали / не найден),
даже когда старые колёса остаются на машине и добавляется одно новое. Теперь
развилка: доложить — комплект тот же, колёса прибавляются; заменить — прежний
порядок с закрытием.

В шапке комплекта видно, сколько и когда доложили. Подпись кнопки следует
выбору: «Доложить 1 покрышку» вместо «Выдать комплект».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:48:53 +10:00
co-authored by Claude Opus 5
parent 771558a551
commit 8deddf2630
2 changed files with 86 additions and 25 deletions
@@ -379,6 +379,18 @@ export interface TyreStockRow {
fits: boolean; fits: boolean;
} }
/** Докладка в выданный комплект — ещё колесо на ту же машину. */
export interface TyreAddition {
id: number;
part_id: number;
part_name: string | null;
qty: number;
grade: TyreGrade | null;
created_at: string;
created_by: string | null;
comment: string | null;
}
export interface TyreSet { export interface TyreSet {
id: number; id: number;
car_id: number | null; car_id: number | null;
@@ -400,6 +412,8 @@ export interface TyreSet {
grade_in: TyreGrade | null; grade_in: TyreGrade | null;
close_comment: string | null; close_comment: string | null;
comment: string | null; comment: string | null;
/** Докладки. `qty` выше их уже включает: это все колёса на машине. */
additions?: TyreAddition[];
} }
export interface TyreContext { export interface TyreContext {
@@ -437,6 +451,8 @@ export async function issueTyres(
place_id?: number | null; place_id?: number | null;
comment?: string | null; comment?: string | null;
close?: TyreClose | null; close?: TyreClose | null;
/** Доложить в открытый комплект вместо выдачи нового (тогда close не нужен). */
add_to_open?: boolean;
}, },
idempotencyKey: string, idempotencyKey: string,
): Promise<TyreSet> { ): Promise<TyreSet> {
@@ -53,9 +53,11 @@ function daysOn(iso: string): number {
/** Подпись кнопки выдачи. «Комплект» — только когда колёс действительно четыре: /** Подпись кнопки выдачи. «Комплект» — только когда колёс действительно четыре:
* с тех пор как выдавать можно поштучно, на одной покрышке эта подпись врала. */ * с тех пор как выдавать можно поштучно, на одной покрышке эта подпись врала. */
function issueLabel(qty: number): string { function issueLabel(qty: number, addToOpen: boolean): string {
const noun = qty === 1 ? "покрышку" : "покрышки";
if (addToOpen) return `Доложить ${qty} ${noun}`;
if (qty === 4) return "Выдать комплект"; if (qty === 4) return "Выдать комплект";
return `Выдать ${qty} ${qty === 1 ? "покрышку" : "покрышки"}`; return `Выдать ${qty} ${noun}`;
} }
export default function TyresPage() { export default function TyresPage() {
@@ -179,6 +181,7 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [addToOpen, setAddToOpen] = useState(false);
// Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи // Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи
// должен попасть в ТУ ЖЕ выдачу, а не создать вторую. // должен попасть в ТУ ЖЕ выдачу, а не создать вторую.
const [idemKey] = useState(() => crypto.randomUUID()); const [idemKey] = useState(() => crypto.randomUUID());
@@ -208,7 +211,10 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
} }
const openSet = ctx.open_set; const openSet = ctx.open_set;
const needClose = openSet !== null && closeAction === null; // «Доложить» — когда старые колёса остаются на машине, а добавляется ещё
// одно-два. Закрывать комплект ради этого нельзя: «списали» врёт на живой
// резине, «вернули» приходует на склад то, что с машины не снимали.
const needClose = openSet !== null && !addToOpen && closeAction === null;
const chosen = ctx.stock.find((s) => s.part_id === partId) ?? null; const chosen = ctx.stock.find((s) => s.part_id === partId) ?? null;
const submit = async () => { const submit = async () => {
@@ -225,16 +231,20 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
qty, qty,
grade, grade,
comment: comment.trim() || null, comment: comment.trim() || null,
close: closeAction add_to_open: addToOpen,
? { close:
action: closeAction, !addToOpen && closeAction
grade: closeAction === "returned" ? closeGrade : undefined, ? {
} action: closeAction,
: null, grade: closeAction === "returned" ? closeGrade : undefined,
}
: null,
}, },
idemKey, idemKey,
); );
toast.success(`Выдано ${qty} шт · ${ctx.car_number ?? ""}`.trim()); toast.success(
`${addToOpen ? "Доложено" : "Выдано"} ${qty} шт · ${ctx.car_number ?? ""}`.trim(),
);
onDone(); onDone();
} catch (e) { } catch (e) {
toast.error(await apiErrorText(e, "Не удалось выдать резину")); toast.error(await apiErrorText(e, "Не удалось выдать резину"));
@@ -275,20 +285,55 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
{` (${daysOn(openSet.issued_at)} дн.)`} {` (${daysOn(openSet.issued_at)} дн.)`}
{openSet.grade_out ? ` · при выдаче ${TYRE_GRADE_LABEL[openSet.grade_out].toLowerCase()}` : ""} {openSet.grade_out ? ` · при выдаче ${TYRE_GRADE_LABEL[openSet.grade_out].toLowerCase()}` : ""}
</p> </p>
<p className="text-sm mt-2 mb-1">Что с ним?</p> {openSet.additions && openSet.additions.length > 0 && (
<div className="grid grid-cols-3 gap-2"> <p className="text-xs text-muted-foreground mt-0.5">
{(Object.keys(CLOSE_LABEL) as TyreClose["action"][]).map((a) => ( из них доложено:{" "}
<Button {openSet.additions
key={a} .map((a) => `${a.qty} шт ${fmtDate(a.created_at)}`)
variant={closeAction === a ? "default" : "outline"} .join(", ")}
size="sm" </p>
onClick={() => setCloseAction(a)} )}
>
{CLOSE_LABEL[a]} {/* Развилка: доложить к тем колёсам или заменить их целиком. */}
</Button> <div className="grid grid-cols-2 gap-2 mt-2">
))} <Button
variant={addToOpen ? "default" : "outline"}
size="sm"
onClick={() => setAddToOpen(true)}
>
Доложить
</Button>
<Button
variant={!addToOpen ? "default" : "outline"}
size="sm"
onClick={() => setAddToOpen(false)}
>
Заменить
</Button>
</div> </div>
{closeAction === "returned" && (
{addToOpen ? (
<p className="text-xs text-muted-foreground mt-2">
Комплект остаётся тем же, колёса добавятся к нему. Размер должен совпадать.
</p>
) : (
<>
<p className="text-sm mt-3 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>
</>
)}
{!addToOpen && closeAction === "returned" && (
<div className="mt-3"> <div className="mt-3">
<p className="text-sm mb-1">Состояние снятой резины</p> <p className="text-sm mb-1">Состояние снятой резины</p>
<GradePicker value={closeGrade} onChange={setCloseGrade} /> <GradePicker value={closeGrade} onChange={setCloseGrade} />
@@ -383,8 +428,8 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
{saving {saving
? "Выдаём…" ? "Выдаём…"
: needClose : needClose
? "Сначала закройте предыдущий комплект" ? "Сначала выберите: доложить или заменить"
: issueLabel(qty)} : issueLabel(qty, addToOpen)}
</Button> </Button>
</div> </div>
</main> </main>