Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0ec64da9 | ||
|
|
3b7b2ae56d | ||
|
|
205078c5ce | ||
|
|
8eac0b74fc | ||
|
|
9642eb6e45 | ||
|
|
087693877d |
@@ -14,6 +14,7 @@ export interface FeedItem {
|
||||
mechanic_name: string;
|
||||
works_preview: FeedWorkBrief[];
|
||||
works_extra: number;
|
||||
works_total: number; // сумма работ без запчастей
|
||||
photo_url: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,5 +31,29 @@
|
||||
* { @apply border-border; }
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
/* iOS PWA: status bar (status-bar-style=black-translucent) и home-indicator
|
||||
перекрывают контент при viewport-fit=cover. Сдвигаем body content вниз
|
||||
на высоту safe-area, чтобы шапки/кнопки не уезжали под системные зоны. */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* iOS PWA: position:sticky + top:0 прилипает к самому верху viewport,
|
||||
т.е. под статус-бар (containing-block тут не помогает). Переопределяем
|
||||
top у всех Tailwind .top-0, чтобы шапки залипали ниже notch. */
|
||||
.sticky.top-0,
|
||||
.fixed.top-0 {
|
||||
top: env(safe-area-inset-top);
|
||||
}
|
||||
/* Full-screen page-replacement модалки (fixed inset-0 с app background) —
|
||||
поиск работ, аннотация фото, zone-detail, inspection-editor. Шапка внутри
|
||||
должна сидеть ниже status-bar, нижние кнопки выше home-indicator.
|
||||
bg-black/40·50·90 диалоги/просмотрщики не задеваем — у них inset-0 full-bleed. */
|
||||
.fixed.inset-0.bg-background,
|
||||
.fixed.inset-0.bg-background\/95 {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ interface WorkRow {
|
||||
work_catalog_id: number;
|
||||
name: string; // снапшот имени из прайса
|
||||
price_catalog: number;
|
||||
price_applied: number;
|
||||
// "" допустим в процессе редактирования — иначе React при очистке inputа
|
||||
// сразу подставляет "0" и его приходится курсором обходить, см. жалобу
|
||||
// механиков. На submit пустое трактуется как 0 (или как ошибка валидации).
|
||||
price_applied: number | "";
|
||||
unlocked: boolean; // карандашик разблокировал поле
|
||||
override_comment: string;
|
||||
}
|
||||
@@ -67,8 +70,6 @@ interface PartRow {
|
||||
|
||||
interface OilChangeState {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
|
||||
next_in_km: number;
|
||||
sticker: PhotoState | null;
|
||||
}
|
||||
@@ -107,8 +108,6 @@ export default function CreateRepairPage() {
|
||||
const [parts, setParts] = useState<PartRow[]>([]);
|
||||
const [oilChange, setOilChange] = useState<OilChangeState>({
|
||||
enabled: false,
|
||||
mileage_at_change: null,
|
||||
mileage_is_dirty: false,
|
||||
next_in_km: 10000,
|
||||
sticker: null,
|
||||
});
|
||||
@@ -130,13 +129,9 @@ export default function CreateRepairPage() {
|
||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||
}, [carId]);
|
||||
|
||||
// У24: мягкая подписка пробега блока масла на шапочный.
|
||||
useEffect(() => {
|
||||
if (oilChange.enabled && !oilChange.mileage_is_dirty) {
|
||||
setOilChange((oc) => ({ ...oc, mileage_at_change: mileage }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mileage, oilChange.enabled]);
|
||||
// У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
|
||||
// в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
|
||||
// не заставлять механика вводить одно и то же дважды.
|
||||
|
||||
// ── Photo upload ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -259,7 +254,14 @@ export default function CreateRepairPage() {
|
||||
setPartsDraft({ name: q, suggestions: [] });
|
||||
if (!q.trim()) return;
|
||||
const list = await suggestParts(q);
|
||||
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
|
||||
// У43 race-fix: пока запрос для "К" летел, юзер дописал до "Кепу" — поздний
|
||||
// ответ не должен перезаписывать .name старым значением (это и есть
|
||||
// «текст частично сам стирается»). Применяем suggestions только если
|
||||
// ввод не менялся; иначе тихо роняем ответ.
|
||||
setPartsDraft((prev) => {
|
||||
if (prev.name !== q) return prev;
|
||||
return { name: q, suggestions: list.map((s) => s.name) };
|
||||
});
|
||||
};
|
||||
|
||||
const addPart = (name: string) => {
|
||||
@@ -318,7 +320,8 @@ export default function CreateRepairPage() {
|
||||
oil_change: oilChange.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
mileage_at_change: oilChange.mileage_at_change,
|
||||
// Один источник истины — главный пробег ремонта (см. У24).
|
||||
mileage_at_change: mileage,
|
||||
next_in_km: oilChange.next_in_km,
|
||||
sticker_photo_uuid:
|
||||
oilChange.sticker?.status === "uploaded"
|
||||
@@ -501,15 +504,19 @@ export default function CreateRepairPage() {
|
||||
min={0}
|
||||
value={w.price_applied}
|
||||
disabled={!w.unlocked}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
// Пустое поле остаётся пустым в стейте (не сбрасываем в 0),
|
||||
// чтобы юзер мог сразу набрать новую цену поверх.
|
||||
const next: number | "" = v === "" ? "" : Number(v);
|
||||
setWorks((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === w.rowKey
|
||||
? { ...row, price_applied: Number(e.target.value) }
|
||||
? { ...row, price_applied: next }
|
||||
: row,
|
||||
),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button
|
||||
@@ -618,21 +625,6 @@ export default function CreateRepairPage() {
|
||||
</label>
|
||||
{oilChange.enabled && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm">Пробег на момент замены</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={oilChange.mileage_at_change ?? ""}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({
|
||||
...oc,
|
||||
mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
|
||||
mileage_is_dirty: true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Следующая замена через (км)</Label>
|
||||
<Input
|
||||
|
||||
@@ -36,7 +36,6 @@ interface PartRow {
|
||||
|
||||
interface OilState {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
next_in_km: number | null;
|
||||
}
|
||||
|
||||
@@ -60,7 +59,7 @@ export default function EditRepairPage() {
|
||||
const [mileage, setMileage] = useState<number | null>(null);
|
||||
const [works, setWorks] = useState<WorkRow[]>([]);
|
||||
const [parts, setParts] = useState<PartRow[]>([]);
|
||||
const [oil, setOil] = useState<OilState>({ enabled: false, mileage_at_change: null, next_in_km: null });
|
||||
const [oil, setOil] = useState<OilState>({ enabled: false, next_in_km: null });
|
||||
|
||||
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
|
||||
{ open: false, q: "", results: [] }
|
||||
@@ -89,7 +88,6 @@ export default function EditRepairPage() {
|
||||
if (d.oil_change) {
|
||||
setOil({
|
||||
enabled: true,
|
||||
mileage_at_change: d.oil_change.mileage_at_change,
|
||||
next_in_km: d.oil_change.next_in_km,
|
||||
});
|
||||
}
|
||||
@@ -174,7 +172,8 @@ export default function EditRepairPage() {
|
||||
qty: p.qty,
|
||||
})),
|
||||
oil_change: oil.enabled
|
||||
? { enabled: true, mileage_at_change: oil.mileage_at_change, next_in_km: oil.next_in_km }
|
||||
// Один источник истины — главный пробег ремонта.
|
||||
? { enabled: true, mileage_at_change: mileage, next_in_km: oil.next_in_km }
|
||||
: { enabled: false, mileage_at_change: null, next_in_km: null },
|
||||
});
|
||||
navigate(`/repairs/${detail.id}`, { replace: true });
|
||||
@@ -343,19 +342,7 @@ export default function EditRepairPage() {
|
||||
Замена масла
|
||||
</label>
|
||||
{oil.enabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Пробег</Label>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={oil.mileage_at_change ?? ""}
|
||||
onChange={(e) => setOil((o) => ({
|
||||
...o, mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3">
|
||||
<Label className="text-xs">Через, км</Label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -366,7 +353,6 @@ export default function EditRepairPage() {
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -83,8 +83,19 @@ export default function RepairsFeedPage() {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Итого слева — сумма работ загруженных ремонтов; Обновить справа. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Итого:</span>{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{fmtMoney(items.reduce((s, it) => s + (it.works_total || 0), 0))} ₽
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({items.length} {pluralRepairs(items.length)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
@@ -176,13 +187,32 @@ function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Механик: {item.mechanic_name}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
|
||||
<div className="text-sm font-semibold tabular-nums flex-shrink-0">
|
||||
{fmtMoney(item.works_total || 0)} ₽
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n));
|
||||
}
|
||||
|
||||
function pluralRepairs(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "ремонтов";
|
||||
if (mod10 === 1) return "ремонт";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "ремонта";
|
||||
return "ремонтов";
|
||||
}
|
||||
|
||||
|
||||
function WorksLine({ works, extra }: { works: string[]; extra: number }) {
|
||||
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
|
||||
if (works.length === 0) return null;
|
||||
|
||||
Reference in New Issue
Block a user