Compare commits
6
Commits
85f3a5c682
...
fd0ec64da9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0ec64da9 | ||
|
|
3b7b2ae56d | ||
|
|
205078c5ce | ||
|
|
8eac0b74fc | ||
|
|
9642eb6e45 | ||
|
|
087693877d |
@@ -14,6 +14,7 @@ export interface FeedItem {
|
|||||||
mechanic_name: string;
|
mechanic_name: string;
|
||||||
works_preview: FeedWorkBrief[];
|
works_preview: FeedWorkBrief[];
|
||||||
works_extra: number;
|
works_extra: number;
|
||||||
|
works_total: number; // сумма работ без запчастей
|
||||||
photo_url: string | null;
|
photo_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,29 @@
|
|||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-sans;
|
@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;
|
work_catalog_id: number;
|
||||||
name: string; // снапшот имени из прайса
|
name: string; // снапшот имени из прайса
|
||||||
price_catalog: number;
|
price_catalog: number;
|
||||||
price_applied: number;
|
// "" допустим в процессе редактирования — иначе React при очистке inputа
|
||||||
|
// сразу подставляет "0" и его приходится курсором обходить, см. жалобу
|
||||||
|
// механиков. На submit пустое трактуется как 0 (или как ошибка валидации).
|
||||||
|
price_applied: number | "";
|
||||||
unlocked: boolean; // карандашик разблокировал поле
|
unlocked: boolean; // карандашик разблокировал поле
|
||||||
override_comment: string;
|
override_comment: string;
|
||||||
}
|
}
|
||||||
@@ -67,8 +70,6 @@ interface PartRow {
|
|||||||
|
|
||||||
interface OilChangeState {
|
interface OilChangeState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mileage_at_change: number | null;
|
|
||||||
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
|
|
||||||
next_in_km: number;
|
next_in_km: number;
|
||||||
sticker: PhotoState | null;
|
sticker: PhotoState | null;
|
||||||
}
|
}
|
||||||
@@ -107,8 +108,6 @@ export default function CreateRepairPage() {
|
|||||||
const [parts, setParts] = useState<PartRow[]>([]);
|
const [parts, setParts] = useState<PartRow[]>([]);
|
||||||
const [oilChange, setOilChange] = useState<OilChangeState>({
|
const [oilChange, setOilChange] = useState<OilChangeState>({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
mileage_at_change: null,
|
|
||||||
mileage_is_dirty: false,
|
|
||||||
next_in_km: 10000,
|
next_in_km: 10000,
|
||||||
sticker: null,
|
sticker: null,
|
||||||
});
|
});
|
||||||
@@ -130,13 +129,9 @@ export default function CreateRepairPage() {
|
|||||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||||
}, [carId]);
|
}, [carId]);
|
||||||
|
|
||||||
// У24: мягкая подписка пробега блока масла на шапочный.
|
// У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
|
||||||
useEffect(() => {
|
// в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
|
||||||
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]);
|
|
||||||
|
|
||||||
// ── Photo upload ─────────────────────────────────────────────────────────
|
// ── Photo upload ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -259,7 +254,14 @@ export default function CreateRepairPage() {
|
|||||||
setPartsDraft({ name: q, suggestions: [] });
|
setPartsDraft({ name: q, suggestions: [] });
|
||||||
if (!q.trim()) return;
|
if (!q.trim()) return;
|
||||||
const list = await suggestParts(q);
|
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) => {
|
const addPart = (name: string) => {
|
||||||
@@ -318,7 +320,8 @@ export default function CreateRepairPage() {
|
|||||||
oil_change: oilChange.enabled
|
oil_change: oilChange.enabled
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
mileage_at_change: oilChange.mileage_at_change,
|
// Один источник истины — главный пробег ремонта (см. У24).
|
||||||
|
mileage_at_change: mileage,
|
||||||
next_in_km: oilChange.next_in_km,
|
next_in_km: oilChange.next_in_km,
|
||||||
sticker_photo_uuid:
|
sticker_photo_uuid:
|
||||||
oilChange.sticker?.status === "uploaded"
|
oilChange.sticker?.status === "uploaded"
|
||||||
@@ -501,15 +504,19 @@ export default function CreateRepairPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
value={w.price_applied}
|
value={w.price_applied}
|
||||||
disabled={!w.unlocked}
|
disabled={!w.unlocked}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
// Пустое поле остаётся пустым в стейте (не сбрасываем в 0),
|
||||||
|
// чтобы юзер мог сразу набрать новую цену поверх.
|
||||||
|
const next: number | "" = v === "" ? "" : Number(v);
|
||||||
setWorks((arr) =>
|
setWorks((arr) =>
|
||||||
arr.map((row) =>
|
arr.map((row) =>
|
||||||
row.rowKey === w.rowKey
|
row.rowKey === w.rowKey
|
||||||
? { ...row, price_applied: Number(e.target.value) }
|
? { ...row, price_applied: next }
|
||||||
: row,
|
: row,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -618,21 +625,6 @@ export default function CreateRepairPage() {
|
|||||||
</label>
|
</label>
|
||||||
{oilChange.enabled && (
|
{oilChange.enabled && (
|
||||||
<div className="mt-3 space-y-3">
|
<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>
|
<div>
|
||||||
<Label className="text-sm">Следующая замена через (км)</Label>
|
<Label className="text-sm">Следующая замена через (км)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ interface PartRow {
|
|||||||
|
|
||||||
interface OilState {
|
interface OilState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mileage_at_change: number | null;
|
|
||||||
next_in_km: number | null;
|
next_in_km: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ export default function EditRepairPage() {
|
|||||||
const [mileage, setMileage] = useState<number | null>(null);
|
const [mileage, setMileage] = useState<number | null>(null);
|
||||||
const [works, setWorks] = useState<WorkRow[]>([]);
|
const [works, setWorks] = useState<WorkRow[]>([]);
|
||||||
const [parts, setParts] = useState<PartRow[]>([]);
|
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[] }>(
|
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
|
||||||
{ open: false, q: "", results: [] }
|
{ open: false, q: "", results: [] }
|
||||||
@@ -89,7 +88,6 @@ export default function EditRepairPage() {
|
|||||||
if (d.oil_change) {
|
if (d.oil_change) {
|
||||||
setOil({
|
setOil({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
mileage_at_change: d.oil_change.mileage_at_change,
|
|
||||||
next_in_km: d.oil_change.next_in_km,
|
next_in_km: d.oil_change.next_in_km,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,7 +172,8 @@ export default function EditRepairPage() {
|
|||||||
qty: p.qty,
|
qty: p.qty,
|
||||||
})),
|
})),
|
||||||
oil_change: oil.enabled
|
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 },
|
: { enabled: false, mileage_at_change: null, next_in_km: null },
|
||||||
});
|
});
|
||||||
navigate(`/repairs/${detail.id}`, { replace: true });
|
navigate(`/repairs/${detail.id}`, { replace: true });
|
||||||
@@ -343,29 +342,16 @@ export default function EditRepairPage() {
|
|||||||
Замена масла
|
Замена масла
|
||||||
</label>
|
</label>
|
||||||
{oil.enabled && (
|
{oil.enabled && (
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3">
|
||||||
<div>
|
<Label className="text-xs">Через, км</Label>
|
||||||
<Label className="text-xs">Пробег</Label>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
inputMode="numeric"
|
||||||
inputMode="numeric"
|
value={oil.next_in_km ?? 10000}
|
||||||
value={oil.mileage_at_change ?? ""}
|
onChange={(e) => setOil((o) => ({
|
||||||
onChange={(e) => setOil((o) => ({
|
...o, next_in_km: e.target.value === "" ? null : Number(e.target.value),
|
||||||
...o, mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
|
}))}
|
||||||
}))}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Через, км</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={oil.next_in_km ?? 10000}
|
|
||||||
onChange={(e) => setOil((o) => ({
|
|
||||||
...o, next_in_km: e.target.value === "" ? null : Number(e.target.value),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -83,8 +83,19 @@ export default function RepairsFeedPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
@@ -176,13 +187,32 @@ function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
|||||||
<div className="text-xs text-muted-foreground truncate">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
Механик: {item.mechanic_name}
|
Механик: {item.mechanic_name}
|
||||||
</div>
|
</div>
|
||||||
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
|
<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>
|
</div>
|
||||||
</button>
|
</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 }) {
|
function WorksLine({ works, extra }: { works: string[]; extra: number }) {
|
||||||
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
|
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
|
||||||
if (works.length === 0) return null;
|
if (works.length === 0) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user