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;
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user