repairs: детали из складского каталога вместо свободного текста
Блок «Запчасти» — модалка-пикер по складскому каталогу (searchParts): поиск по названию/артикулу, остаток и пометка «подходит», тумблер «Показать все» (снимает фильтр применимости). Выбор шлёт part_id — бэкенд списывает деталь со склада. Свободный ввод убран. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,18 @@ export interface PartSuggestion {
|
||||
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 {
|
||||
photo_uuid: string;
|
||||
extension: string;
|
||||
@@ -53,8 +65,9 @@ export interface CreateWorkPayload {
|
||||
}
|
||||
|
||||
export interface CreatePartPayload {
|
||||
name: string;
|
||||
part_id: number;
|
||||
qty: number;
|
||||
name?: string; // снимок имени (сервер возьмёт из каталога, если пусто)
|
||||
}
|
||||
|
||||
export interface CreateOilChangePayload {
|
||||
@@ -102,6 +115,22 @@ export async function suggestParts(q: string): Promise<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> {
|
||||
return api
|
||||
.post("repairs/photos/presign", {
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
getCarContext,
|
||||
presignPhoto,
|
||||
searchWorks,
|
||||
suggestParts,
|
||||
searchParts,
|
||||
uploadPhotoToTmp,
|
||||
type CarContext,
|
||||
type CreatePhotoPayload,
|
||||
type MechWork,
|
||||
type PartCatalogItem,
|
||||
type PresignResponse,
|
||||
} from "@/api/repairsCreate";
|
||||
|
||||
@@ -57,7 +58,10 @@ interface WorkRow {
|
||||
|
||||
interface PartRow {
|
||||
rowKey: string;
|
||||
part_id: number;
|
||||
name: string;
|
||||
article: string | null;
|
||||
in_stock: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
@@ -238,20 +242,49 @@ export default function CreateRepairPage() {
|
||||
|
||||
// ── 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) => {
|
||||
setPartsDraft({ name: q, suggestions: [] });
|
||||
if (!q.trim()) return;
|
||||
const list = await suggestParts(q);
|
||||
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
|
||||
const loadParts = async (q: string, showAll: boolean) => {
|
||||
setPartsPicker((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const list = await searchParts(q, carId, showAll);
|
||||
setPartsPicker((s) => ({ ...s, results: list, loading: false }));
|
||||
} catch {
|
||||
setPartsPicker((s) => ({ ...s, results: [], loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const addPart = (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]);
|
||||
setPartsDraft({ name: "", suggestions: [] });
|
||||
const openPartsPicker = () => {
|
||||
setPartsPicker({ open: true, q: "", showAll: false, results: [], loading: true });
|
||||
void loadParts("", false);
|
||||
};
|
||||
|
||||
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) =>
|
||||
@@ -300,7 +333,7 @@ export default function CreateRepairPage() {
|
||||
manual_override: w.unlocked,
|
||||
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,
|
||||
oil_changes: oilChange.enabled
|
||||
? (["engine", "transmission"] as const)
|
||||
@@ -518,14 +551,34 @@ export default function CreateRepairPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Запчасти */}
|
||||
{/* Запчасти — из складского каталога (списываются со склада) */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-sm">Запчасти и материалы</Label>
|
||||
{parts.length > 0 && (
|
||||
<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">
|
||||
{parts.map((p) => (
|
||||
<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
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -546,32 +599,6 @@ export default function CreateRepairPage() {
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Замена масла */}
|
||||
@@ -716,6 +743,88 @@ export default function CreateRepairPage() {
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user