repairs: детали из складского каталога вместо свободного текста

Блок «Запчасти» — модалка-пикер по складскому каталогу (searchParts):
поиск по названию/артикулу, остаток и пометка «подходит», тумблер
«Показать все» (снимает фильтр применимости). Выбор шлёт part_id —
бэкенд списывает деталь со склада. Свободный ввод убран.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 19:16:26 +10:00
co-authored by Claude Opus 4.8
parent ce4b50109a
commit 226b1ca6c1
2 changed files with 182 additions and 44 deletions
+30 -1
View File
@@ -29,6 +29,18 @@ export interface PartSuggestion {
name: string; name: string;
} }
/** Позиция складского каталога для подбора детали в ремонт. */
export interface PartCatalogItem {
id: number;
name: string;
article: string | null;
brand: string | null;
unit: string;
in_stock: number; // суммарный остаток (может быть < 0)
price_hint: number | string | null;
fits: boolean; // подходит этой машине
}
export interface PresignResponse { export interface PresignResponse {
photo_uuid: string; photo_uuid: string;
extension: string; extension: string;
@@ -53,8 +65,9 @@ export interface CreateWorkPayload {
} }
export interface CreatePartPayload { export interface CreatePartPayload {
name: string; part_id: number;
qty: number; qty: number;
name?: string; // снимок имени (сервер возьмёт из каталога, если пусто)
} }
export interface CreateOilChangePayload { export interface CreateOilChangePayload {
@@ -102,6 +115,22 @@ export async function suggestParts(q: string): Promise<PartSuggestion[]> {
.json<PartSuggestion[]>(); .json<PartSuggestion[]>();
} }
/**
* Поиск детали в складском каталоге для конкретной машины.
* По умолчанию — только подходящие этой машине + без привязки к марке;
* showAll=true снимает фильтр применимости.
*/
export async function searchParts(
q: string,
carId: number,
showAll = false,
): Promise<PartCatalogItem[]> {
const params = new URLSearchParams({ car_id: String(carId) });
if (q.trim()) params.set("q", q.trim());
if (showAll) params.set("all", "true");
return api.get(`parts/search?${params.toString()}`).json<PartCatalogItem[]>();
}
export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> { export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> {
return api return api
.post("repairs/photos/presign", { .post("repairs/photos/presign", {
@@ -26,11 +26,12 @@ import {
getCarContext, getCarContext,
presignPhoto, presignPhoto,
searchWorks, searchWorks,
suggestParts, searchParts,
uploadPhotoToTmp, uploadPhotoToTmp,
type CarContext, type CarContext,
type CreatePhotoPayload, type CreatePhotoPayload,
type MechWork, type MechWork,
type PartCatalogItem,
type PresignResponse, type PresignResponse,
} from "@/api/repairsCreate"; } from "@/api/repairsCreate";
@@ -57,7 +58,10 @@ interface WorkRow {
interface PartRow { interface PartRow {
rowKey: string; rowKey: string;
part_id: number;
name: string; name: string;
article: string | null;
in_stock: number;
qty: number; qty: number;
} }
@@ -238,20 +242,49 @@ export default function CreateRepairPage() {
// ── Parts ──────────────────────────────────────────────────────────────── // ── Parts ────────────────────────────────────────────────────────────────
const [partsDraft, setPartsDraft] = useState<{ name: string; suggestions: string[] }>({ name: "", suggestions: [] }); const [partsPicker, setPartsPicker] = useState<{
open: boolean; q: string; showAll: boolean; results: PartCatalogItem[]; loading: boolean;
}>({ open: false, q: "", showAll: false, results: [], loading: false });
const queryPartSuggestions = async (q: string) => { const loadParts = async (q: string, showAll: boolean) => {
setPartsDraft({ name: q, suggestions: [] }); setPartsPicker((s) => ({ ...s, loading: true }));
if (!q.trim()) return; try {
const list = await suggestParts(q); const list = await searchParts(q, carId, showAll);
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) }); setPartsPicker((s) => ({ ...s, results: list, loading: false }));
} catch {
setPartsPicker((s) => ({ ...s, results: [], loading: false }));
}
}; };
const addPart = (name: string) => { const openPartsPicker = () => {
const trimmed = name.trim(); setPartsPicker({ open: true, q: "", showAll: false, results: [], loading: true });
if (!trimmed) return; void loadParts("", false);
setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]); };
setPartsDraft({ name: "", suggestions: [] });
const queryParts = (q: string) => {
setPartsPicker((s) => ({ ...s, q }));
void loadParts(q, partsPicker.showAll);
};
const togglePartsShowAll = () => {
const next = !partsPicker.showAll;
setPartsPicker((s) => ({ ...s, showAll: next }));
void loadParts(partsPicker.q, next);
};
const addPartFromCatalog = (item: PartCatalogItem) => {
setParts((arr) => {
// Та же деталь уже в списке — увеличиваем количество, а не плодим строку.
const existing = arr.find((p) => p.part_id === item.id);
if (existing) {
return arr.map((p) => (p.part_id === item.id ? { ...p, qty: p.qty + 1 } : p));
}
return [
...arr,
{ rowKey: uuid4(), part_id: item.id, name: item.name, article: item.article, in_stock: item.in_stock, qty: 1 },
];
});
setPartsPicker({ open: false, q: "", showAll: false, results: [], loading: false });
}; };
const removePart = (rowKey: string) => const removePart = (rowKey: string) =>
@@ -300,7 +333,7 @@ export default function CreateRepairPage() {
manual_override: w.unlocked, manual_override: w.unlocked,
override_comment: w.unlocked ? w.override_comment.trim() : null, override_comment: w.unlocked ? w.override_comment.trim() : null,
})), })),
parts: parts.map((p) => ({ name: p.name, qty: p.qty })), parts: parts.map((p) => ({ part_id: p.part_id, qty: p.qty, name: p.name })),
photos: photoPayload, photos: photoPayload,
oil_changes: oilChange.enabled oil_changes: oilChange.enabled
? (["engine", "transmission"] as const) ? (["engine", "transmission"] as const)
@@ -518,14 +551,34 @@ export default function CreateRepairPage() {
)} )}
</section> </section>
{/* Запчасти */} {/* Запчасти — из складского каталога (списываются со склада) */}
<section> <section>
<Label className="text-sm">Запчасти и материалы</Label> <div className="flex items-baseline justify-between">
{parts.length > 0 && ( <Label className="text-sm">Запчасти и материалы</Label>
<Button variant="outline" size="sm" onClick={openPartsPicker}>
<Plus className="h-3 w-3 mr-1" />
Добавить деталь
</Button>
</div>
{parts.length === 0 ? (
<div className="text-xs text-muted-foreground mt-2">
Детали выбираются со склада спишутся при сохранении
</div>
) : (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{parts.map((p) => ( {parts.map((p) => (
<li key={p.rowKey} className="flex items-center gap-2 text-sm"> <li key={p.rowKey} className="flex items-center gap-2 text-sm">
<span className="flex-1">{p.name}</span> <div className="flex-1 min-w-0">
<div className="truncate">{p.name}</div>
<div className="text-xs text-muted-foreground">
{p.article ? `арт. ${p.article} · ` : ""}
{p.in_stock > 0
? `на складе: ${p.in_stock}`
: p.in_stock < 0
? `на складе: ${p.in_stock}`
: "нет в наличии"}
</div>
</div>
<Input <Input
type="number" type="number"
min={1} min={1}
@@ -546,32 +599,6 @@ export default function CreateRepairPage() {
))} ))}
</ul> </ul>
)} )}
<div className="mt-2 flex gap-2">
<Input
value={partsDraft.name}
onChange={(e) => queryPartSuggestions(e.target.value)}
placeholder="Например, Свеча NGK"
onKeyDown={(e) => e.key === "Enter" && addPart(partsDraft.name)}
/>
<Button onClick={() => addPart(partsDraft.name)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{partsDraft.suggestions.length > 0 && (
<ul className="mt-1 border rounded-md bg-popover divide-y">
{partsDraft.suggestions.map((s) => (
<li key={s}>
<button
type="button"
className="w-full text-left text-sm px-3 py-2 hover:bg-accent/40"
onClick={() => addPart(s)}
>
{s}
</button>
</li>
))}
</ul>
)}
</section> </section>
{/* Замена масла */} {/* Замена масла */}
@@ -716,6 +743,88 @@ export default function CreateRepairPage() {
</main> </main>
</div> </div>
)} )}
{/* Parts picker modal — каталог склада */}
{partsPicker.open && (
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
<header className="border-b p-3 flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPartsPicker({ open: false, q: "", showAll: false, results: [], loading: false })}
>
Отмена
</Button>
<Input
autoFocus
placeholder="Поиск детали — название или артикул…"
value={partsPicker.q}
onChange={(e) => queryParts(e.target.value)}
/>
</header>
<div className="border-b px-3 py-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{partsPicker.showAll ? "Все детали каталога" : "Подходящие этой машине"}
</span>
<button
type="button"
onClick={togglePartsShowAll}
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
>
{partsPicker.showAll ? "← Только подходящие" : "Показать все →"}
</button>
</div>
<main className="flex-1 overflow-y-auto">
<ul className="divide-y">
{partsPicker.results.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => addPartFromCatalog(p)}
className="w-full text-left p-3 hover:bg-accent/40 flex items-center justify-between gap-3"
>
<span className="flex-1 min-w-0">
<span className="block truncate">
{p.fits && <span className="text-primary mr-1"></span>}
{p.name}
</span>
<span className="block text-xs text-muted-foreground truncate">
{p.article ? `арт. ${p.article}` : "без артикула"}
{p.brand ? ` · ${p.brand}` : ""}
</span>
</span>
<span
className={`text-sm whitespace-nowrap ${
p.in_stock > 0
? "text-muted-foreground"
: p.in_stock < 0
? "text-destructive"
: "text-muted-foreground"
}`}
>
{p.in_stock > 0
? `${p.in_stock} шт`
: p.in_stock < 0
? `${p.in_stock} шт`
: "нет"}
</span>
</button>
</li>
))}
{!partsPicker.loading && partsPicker.results.length === 0 && (
<li className="p-6 text-center text-sm text-muted-foreground">
{partsPicker.q
? "Ничего не найдено. Если детали нет в каталоге — заведите карточку на складе."
: "Нет подходящих деталей. Нажмите «Показать все» или заведите карточку на складе."}
</li>
)}
{partsPicker.loading && (
<li className="p-6 text-center text-sm text-muted-foreground">Загрузка</li>
)}
</ul>
</main>
</div>
)}
</div> </div>
); );
} }