feat(tyres): на машине с комплектом — выбор «доложить» или «заменить»
Раньше экран требовал закрыть старый комплект (вернули / списали / не найден), даже когда старые колёса остаются на машине и добавляется одно новое. Теперь развилка: доложить — комплект тот же, колёса прибавляются; заменить — прежний порядок с закрытием. В шапке комплекта видно, сколько и когда доложили. Подпись кнопки следует выбору: «Доложить 1 покрышку» вместо «Выдать комплект». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -379,6 +379,18 @@ export interface TyreStockRow {
|
||||
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 {
|
||||
id: number;
|
||||
car_id: number | null;
|
||||
@@ -400,6 +412,8 @@ export interface TyreSet {
|
||||
grade_in: TyreGrade | null;
|
||||
close_comment: string | null;
|
||||
comment: string | null;
|
||||
/** Докладки. `qty` выше их уже включает: это все колёса на машине. */
|
||||
additions?: TyreAddition[];
|
||||
}
|
||||
|
||||
export interface TyreContext {
|
||||
@@ -437,6 +451,8 @@ export async function issueTyres(
|
||||
place_id?: number | null;
|
||||
comment?: string | null;
|
||||
close?: TyreClose | null;
|
||||
/** Доложить в открытый комплект вместо выдачи нового (тогда close не нужен). */
|
||||
add_to_open?: boolean;
|
||||
},
|
||||
idempotencyKey: string,
|
||||
): 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 "Выдать комплект";
|
||||
return `Выдать ${qty} ${qty === 1 ? "покрышку" : "покрышки"}`;
|
||||
return `Выдать ${qty} ${noun}`;
|
||||
}
|
||||
|
||||
export default function TyresPage() {
|
||||
@@ -179,6 +181,7 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
// Ключ идемпотентности живёт, пока форма открыта: ретрай после обрыва связи
|
||||
// должен попасть в ТУ ЖЕ выдачу, а не создать вторую.
|
||||
const [idemKey] = useState(() => crypto.randomUUID());
|
||||
@@ -208,7 +211,10 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||
}
|
||||
|
||||
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 submit = async () => {
|
||||
@@ -225,16 +231,20 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||
qty,
|
||||
grade,
|
||||
comment: comment.trim() || null,
|
||||
close: closeAction
|
||||
? {
|
||||
action: closeAction,
|
||||
grade: closeAction === "returned" ? closeGrade : undefined,
|
||||
}
|
||||
: null,
|
||||
add_to_open: addToOpen,
|
||||
close:
|
||||
!addToOpen && closeAction
|
||||
? {
|
||||
action: closeAction,
|
||||
grade: closeAction === "returned" ? closeGrade : undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
idemKey,
|
||||
);
|
||||
toast.success(`Выдано ${qty} шт · ${ctx.car_number ?? ""}`.trim());
|
||||
toast.success(
|
||||
`${addToOpen ? "Доложено" : "Выдано"} ${qty} шт · ${ctx.car_number ?? ""}`.trim(),
|
||||
);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast.error(await apiErrorText(e, "Не удалось выдать резину"));
|
||||
@@ -275,20 +285,55 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||
{` (${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>
|
||||
))}
|
||||
{openSet.additions && openSet.additions.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
из них доложено:{" "}
|
||||
{openSet.additions
|
||||
.map((a) => `${a.qty} шт ${fmtDate(a.created_at)}`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Развилка: доложить к тем колёсам или заменить их целиком. */}
|
||||
<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>
|
||||
{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">
|
||||
<p className="text-sm mb-1">Состояние снятой резины</p>
|
||||
<GradePicker value={closeGrade} onChange={setCloseGrade} />
|
||||
@@ -383,8 +428,8 @@ function IssueForm({ carId, onDone }: { carId: number; onDone: () => void }) {
|
||||
{saving
|
||||
? "Выдаём…"
|
||||
: needClose
|
||||
? "Сначала закройте предыдущий комплект"
|
||||
: issueLabel(qty)}
|
||||
? "Сначала выберите: доложить или заменить"
|
||||
: issueLabel(qty, addToOpen)}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user