diff --git a/mechanic-pwa/frontend/src/api/repairsCreate.ts b/mechanic-pwa/frontend/src/api/repairsCreate.ts index cc508e8..d5f0af3 100644 --- a/mechanic-pwa/frontend/src/api/repairsCreate.ts +++ b/mechanic-pwa/frontend/src/api/repairsCreate.ts @@ -78,6 +78,20 @@ export interface CreateRepairPayload { parts: CreatePartPayload[]; photos: CreatePhotoPayload[]; oil_change: CreateOilChangePayload | null; + // Работавшие механики (делёж 1/N). Если не передан — backend сидит + // одного создателя. Порядок = sort_order. + mechanic_ids?: number[]; +} + +// ── Ростер механиков (пикер «Механики») ──────────────────────────────────── + +export interface MechanicRosterItem { + id: number; + full_name: string; +} + +export async function getMechanicsRoster(): Promise { + return api.get("mechanics-roster").json(); } export interface CreateRepairResponse { diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts index 80bc0f2..8b1e122 100644 --- a/mechanic-pwa/frontend/src/api/repairsFeed.ts +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -79,6 +79,12 @@ export interface RepairDetailOil { sticker_orig_url: string | null; } +export interface RepairDetailMechanic { + id: number; + mechanic_id: number; + name: string; +} + export interface RepairDetailResponse { id: number; created_at: string; @@ -93,6 +99,9 @@ export interface RepairDetailResponse { photos: RepairDetailPhoto[]; oil_change: RepairDetailOil | null; total_sum: number; + // Работавшие механики (делёж 1/N total_sum). Может отсутствовать на + // старых ответах backend — фронт делает fallback на одного создателя. + mechanics?: RepairDetailMechanic[]; edited_in_crm: boolean; can_edit: boolean; edit_until: string | null; @@ -127,6 +136,8 @@ export interface PatchRepairPayload { works: PatchWork[]; parts: PatchPart[]; oil_change: PatchOilChange | null; + // Работавшие механики. Опционально — если не передан, набор не меняется. + mechanic_ids?: number[]; } export async function patchRepair(id: number, body: PatchRepairPayload): Promise { diff --git a/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx b/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx new file mode 100644 index 0000000..22bcf68 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx @@ -0,0 +1,168 @@ +/** + * Блок «Механики» формы ремонта — чипы работавших механиков + кнопка + * «Добавить механика» (пикер из ростера) + удаление чипа. + * + * Делёж оплаты — поровну (1/N): доля каждого механика = total_sum / N. + * Здесь показывается информативно (сумма на чел.), если передан totalSum. + * + * Состояние выбора живёт в родителе (как у WorksPickerModal): сюда + * приходит value + onChange. Ростер компонент тянет сам при первом + * открытии пикера (лениво) и кэширует на время жизни. + */ +import { useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { getMechanicsRoster, type MechanicRosterItem } from "@/api/repairsCreate"; + +export interface SelectedMechanic { + mechanic_id: number; + name: string; +} + +interface Props { + value: SelectedMechanic[]; + onChange: (next: SelectedMechanic[]) => void; + // Если задан и механиков >0 — показываем долю (1/N · totalSum). + totalSum?: number; + disabled?: boolean; +} + +export default function MechanicsField({ value, onChange, totalSum, disabled }: Props) { + const [pickerOpen, setPickerOpen] = useState(false); + const [roster, setRoster] = useState(null); + const [rosterError, setRosterError] = useState(null); + const [q, setQ] = useState(""); + + const openPicker = async () => { + setPickerOpen(true); + if (roster) return; + try { + const list = await getMechanicsRoster(); + setRoster(list); + setRosterError(null); + } catch (e) { + setRosterError((e as Error).message || "Не удалось загрузить список механиков"); + } + }; + + const addMechanic = (m: MechanicRosterItem) => { + // Дедуп: один механик один раз (UNIQUE на бэке). + if (!value.some((v) => v.mechanic_id === m.id)) { + onChange([...value, { mechanic_id: m.id, name: m.full_name }]); + } + setPickerOpen(false); + setQ(""); + }; + + const removeMechanic = (mechanicId: number) => + onChange(value.filter((v) => v.mechanic_id !== mechanicId)); + + const share = + totalSum != null && value.length > 0 ? Math.round(totalSum / value.length) : null; + + const filtered = (roster ?? []).filter((m) => + q.trim() ? m.full_name.toLowerCase().includes(q.trim().toLowerCase()) : true, + ); + + return ( +
+
+ + {!disabled && ( + + )} +
+ + {value.length === 0 ? ( +
Минимум один механик
+ ) : ( +
+ {value.map((m) => ( + + {m.name} + {!disabled && ( + + )} + + ))} +
+ )} + + {share != null && value.length > 1 && ( +
+ Делёж поровну: {fmtMoney(share)} ₽ на каждого ({value.length} мех.) +
+ )} + + {/* Пикер механиков из ростера */} + {pickerOpen && ( +
+
+ + setQ(e.target.value)} + /> +
+
+ {rosterError && ( +
+ {rosterError} +
+ )} + {roster === null && !rosterError && ( +
Загрузка…
+ )} + {filtered.map((m) => { + const already = value.some((v) => v.mechanic_id === m.id); + return ( + + ); + })} + {roster !== null && !rosterError && filtered.length === 0 && ( +
+ Ничего не найдено +
+ )} +
+
+ )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx index 032daba..668665f 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -23,6 +23,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm"; import WorksPickerModal from "@/components/repairs/WorksPickerModal"; +import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; +import { getMe } from "@/api/me"; import { createRepair, getCarContext, @@ -111,6 +113,9 @@ export default function CreateRepairPage() { const [photos, setPhotos] = useState([]); const [works, setWorks] = useState([]); const [parts, setParts] = useState([]); + // Работавшие механики (делёж 1/N). По умолчанию — текущий механик + // (создатель), подтягивается из /me. Можно убрать/заменить. + const [mechanics, setMechanics] = useState([]); const [oilChange, setOilChange] = useState({ enabled: false, next_in_km: 10000, @@ -153,6 +158,16 @@ export default function CreateRepairPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [carId, isExternal]); + // Дефолтный механик-создатель: подтягиваем себя из /me. Если /me упал — + // оставляем список пустым (backend всё равно засидит создателя на create). + useEffect(() => { + getMe() + .then((me) => setMechanics([{ mechanic_id: me.id, name: me.name }])) + .catch(() => { + /* noop — список останется пустым, backend засидит создателя */ + }); + }, []); + // У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)» // в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы // не заставлять механика вводить одно и то же дважды. @@ -322,6 +337,11 @@ export default function CreateRepairPage() { ...(isExternal ? { external_car: { plate: externalState!.plate, make: externalState!.make } } : { car_id: carId }), + // Передаём список работавших механиков; если пуст — backend засидит + // создателя сам (см. seed_creator_mechanic). + ...(mechanics.length > 0 + ? { mechanic_ids: mechanics.map((m) => m.mechanic_id) } + : {}), mileage, works: works.map((w) => ({ work_catalog_id: w.work_catalog_id, @@ -573,6 +593,9 @@ export default function CreateRepairPage() { )} + {/* Механики (делёж 1/N) */} + + {/* Запчасти */}
diff --git a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx index 2a3ed55..ce1e710 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx @@ -18,6 +18,7 @@ import { } from "@/api/repairsFeed"; import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate"; import WorksPickerModal from "@/components/repairs/WorksPickerModal"; +import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; interface WorkRow { rowKey: string; @@ -61,6 +62,8 @@ export default function EditRepairPage() { const [works, setWorks] = useState([]); const [parts, setParts] = useState([]); const [oil, setOil] = useState({ enabled: false, next_in_km: null }); + // Работавшие механики (делёж 1/N). + const [mechanics, setMechanics] = useState([]); const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>( { open: false, q: "", results: [] } @@ -92,6 +95,14 @@ export default function EditRepairPage() { next_in_km: d.oil_change.next_in_km, }); } + // Механики: из списка работавших; fallback — создатель (старые ответы). + if (d.mechanics && d.mechanics.length > 0) { + setMechanics(d.mechanics.map((m) => ({ mechanic_id: m.mechanic_id, name: m.name }))); + } else if (d.mechanic_name) { + // Нет id создателя в payload — оставляем имя без id-сопоставления; + // если механик ничего не трогает, mechanic_ids не отправим. + setMechanics([]); + } if (!d.can_edit) { setError("Окно правки закрыто. Изменения отправить нельзя."); } @@ -157,6 +168,12 @@ export default function EditRepairPage() { setError("Минимум одна работа в ремонте"); return; } + // Механики были загружены, но пользователь убрал всех — backend отклонит + // пустой список, перехватываем заранее с понятным текстом. + if ((detail.mechanics?.length ?? 0) > 0 && mechanics.length === 0) { + setError("Минимум один механик в ремонте"); + return; + } setSaving(true); try { @@ -176,6 +193,11 @@ export default function EditRepairPage() { // Один источник истины — главный пробег ремонта. ? { enabled: true, mileage_at_change: mileage, next_in_km: oil.next_in_km } : { enabled: false, mileage_at_change: null, next_in_km: null }, + // Список механиков отправляем, только если он непуст (иначе бэк + // оставит набор как есть; пустой бы вызвал ValueError). + ...(mechanics.length > 0 + ? { mechanic_ids: mechanics.map((m) => m.mechanic_id) } + : {}), }); navigate(`/repairs/${detail.id}`, { replace: true }); } catch (e) { @@ -305,6 +327,9 @@ export default function EditRepairPage() {
+ {/* Механики (делёж 1/N) */} + + {/* Запчасти */}
diff --git a/mechanic-pwa/frontend/src/pages/repairs/RepairDetailPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/RepairDetailPage.tsx index 77e5d75..47807a6 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/RepairDetailPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/RepairDetailPage.tsx @@ -131,10 +131,33 @@ export default function RepairDetailPage() { {data.driver_name}
)} -
- Механик: - {data.mechanic_name || "—"} -
+ {data.mechanics && data.mechanics.length > 0 ? ( +
+ + {data.mechanics.length > 1 ? "Механики: " : "Механик: "} + + + {data.mechanics.map((m) => ( + + {m.name} + + ))} + + {data.mechanics.length > 1 && ( +
+ Делёж поровну: {fmt(data.total_sum / data.mechanics.length)} ₽ на каждого +
+ )} +
+ ) : ( +
+ Механик: + {data.mechanic_name || "—"} +
+ )} {data.mileage != null && (
Пробег: