feat: детальная карточка сборки + цена замера везде

- assembly_detail.js: экран #/c/assembly/:id — статус, адрес, фото, подпись, gcal
- orders.js: сборки кликабельны → #/c/assembly/:id
- app.js: маршрут #/c/assembly/
- selfmeasure.js: цена 2500₽ + 40₽/км за КАД на шаге 1 и шаге 5
- cabinet.js: цена под кнопкой самозамера

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-18 15:35:21 +03:00
parent b75f24e4d7
commit 042dc1a5d3
6 changed files with 199 additions and 3 deletions

View File

@ -1722,6 +1722,10 @@ function routeByHash() {
} else if (location.hash === "#/c/orders") {
if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app);
else init();
} else if (location.hash.startsWith("#/c/assembly/")) {
const assemblyId = decodeURIComponent(location.hash.replace("#/c/assembly/", ""));
if (typeof AssemblyDetailScreen !== "undefined") AssemblyDetailScreen.mount(app, assemblyId);
else init();
} else if (location.hash === "#/c/selfmeasure") {
if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app);
else init();

View File

@ -0,0 +1,174 @@
/* ============================================================
Детальная карточка сборки #/c/assembly/:id
Доступна клиенту, менеджеру, мастеру.
============================================================ */
const AssemblyDetailScreen = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmtDate(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric", month: "long", year: "numeric",
hour: "2-digit", minute: "2-digit"
});
} catch { return iso.slice(0, 16).replace("T", " "); }
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }),
});
if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
const STATUS = {
created: { icon: "🆕", text: "Создана", color: "#8e8e8e" },
scheduled: { icon: "📅", text: "Запланирована", color: "#2980B9" },
in_progress: { icon: "🔨", text: "В процессе", color: "#F39C12" },
done: { icon: "✅", text: "Завершена", color: "#27AE60" },
cancelled: { icon: "❌", text: "Отменена", color: "#C0392B" },
};
function row(label, value, opts = {}) {
if (!value) return "";
return `
<div style="display:flex;justify-content:space-between;align-items:flex-start;
padding:10px 0;border-bottom:1px solid var(--border);">
<div style="font-size:12px;color:var(--muted);flex-shrink:0;margin-right:12px;">${escHtml(label)}</div>
<div style="font-size:13px;font-weight:500;color:${opts.color || "var(--ink)"};text-align:right;">${opts.html ? value : escHtml(value)}</div>
</div>`;
}
async function mount(container, assemblyId) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
// Header
const h = document.createElement("header");
h.className = "podbor-header";
h.innerHTML = `
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Сборка кухни</div>
<div style="width:36px"></div>
`;
h.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
container.appendChild(h);
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
container.appendChild(screen);
try {
const data = await _api("assembly_detail", { assembly_id: assemblyId });
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
const sl = STATUS[data.status] || { icon: "🔧", text: data.status, color: "#8e8e8e" };
// Статус-баннер
const statusBanner = `
<div style="display:flex;align-items:center;gap:10px;padding:16px;
background:var(--surface);border-bottom:1px solid var(--border);">
<div style="font-size:32px;">${sl.icon}</div>
<div>
<div style="font-size:16px;font-weight:700;color:${sl.color};">${escHtml(sl.text)}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">ID: ${escHtml(data.id)}</div>
</div>
</div>`;
// Основные данные
const mainBlock = `
<div style="margin:12px 16px 0;border:1px solid var(--border);border-radius:12px;
padding:0 12px;background:var(--surface);">
${row("Адрес", data.address)}
${row("Объём работ", data.scope_of_work)}
${row("Дата сборки", fmtDate(data.scheduled_at))}
${row("Начало", fmtDate(data.started_at))}
${row("Завершение", fmtDate(data.completed_at))}
</div>`;
// Заметка менеджера
const noteBlock = data.manager_note ? `
<div style="margin:12px 16px 0;padding:12px;background:var(--surface-2,var(--surface));
border:1px solid var(--border);border-radius:12px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);margin-bottom:6px;">Заметка</div>
<div style="font-size:13px;color:var(--ink);line-height:1.5;">${escHtml(data.manager_note)}</div>
</div>` : "";
// Фото результата
const photosAfter = (data.photos_after || []).filter(Boolean);
const photosBlock = photosAfter.length ? `
<div style="margin:12px 16px 0;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);margin-bottom:8px;">Фото результата</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
${photosAfter.map(u => `
<a href="${escHtml(u)}" target="_blank">
<img src="${escHtml(u)}" alt="фото"
style="width:80px;height:80px;object-fit:cover;border-radius:8px;
border:1px solid var(--border);">
</a>`).join("")}
</div>
</div>` : "";
// Подпись
const signBlock = data.signed_by_name ? `
<div style="margin:12px 16px 0;padding:10px 12px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;
display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:12px;color:var(--muted);">Принято клиентом</div>
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(data.signed_by_name)}</div>
</div>
<div style="font-size:12px;color:var(--muted);">${escHtml(fmtDate(data.signed_at) || "")}</div>
</div>` : "";
// Кнопка Google Calendar
const calBtn = data.gcal_event_url ? `
<div style="margin:12px 16px 0;">
<a href="${escHtml(data.gcal_event_url)}" target="_blank"
style="display:flex;align-items:center;justify-content:center;gap:8px;
padding:12px;border:1px solid var(--border);border-radius:10px;
background:var(--surface);text-decoration:none;
font-size:13px;font-weight:600;color:var(--accent);">
📅 Посмотреть в Google Календаре
</a>
</div>` : "";
screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn +
`<div style="height:32px;"></div>`;
} catch (e) {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
}
}
return { mount };
})();

View File

@ -187,6 +187,10 @@ const CabinetScreen = (function () {
<div class="block" style="margin:12px 16px 0;display:flex;flex-direction:column;gap:8px;">
<button class="btn-secondary" data-href="#/c/orders" style="width:100%;">📋 История заказов</button>
<button class="btn-secondary" data-href="#/c/selfmeasure" style="width:100%;">📐 Самозамер кухни</button>
<div style="font-size:12px;color:var(--muted);text-align:center;line-height:1.4;">
Выезд специалиста: <strong style="color:var(--ink);">2 500 </strong>
· за КАД СПб +40 /км
</div>
</div>
<div style="height:32px;"></div>
`;

View File

@ -119,7 +119,7 @@ const OrdersScreen = (function () {
subtitle: a.scope_of_work || null,
statusText: sl.text,
statusColor: sl.color,
href: null,
href: a.id ? `#/c/assembly/${encodeURIComponent(a.id)}` : null,
calUrl: a.gcal_event_url || null,
};
}

View File

@ -134,11 +134,23 @@ const SelfMeasureScreen = (function () {
return ["А"];
}
/* ---- Price info block ---- */
const PRICE_INFO_HTML = `
<div style="margin:12px 16px 0;padding:12px 14px;border-radius:10px;
background:#FFF8E7;border:1px solid #F5A623;">
<div style="font-size:12px;font-weight:700;color:#B7770A;margin-bottom:4px;">💰 Стоимость выезда специалиста</div>
<div style="font-size:13px;color:#5C4800;line-height:1.5;">
В черте КАД Санкт-Петербурга <strong>2 500 </strong><br>
За пределами КАД <strong>2 500 + 40 /км</strong> от кольцевой до адреса
</div>
</div>`;
/* ---- Step 1: Kitchen type ---- */
function renderStep1(state) {
const wrap = document.createElement("div");
wrap.innerHTML = `
<div class="block" style="margin:16px 16px 0;">
${PRICE_INFO_HTML}
<div class="block" style="margin:12px 16px 0;">
<div class="block-head">Выберите тип кухни</div>
</div>
`;
@ -526,7 +538,8 @@ const SelfMeasureScreen = (function () {
const wrap = document.createElement("div");
wrap.innerHTML = `
<div class="block" style="margin:16px 16px 0;">
${PRICE_INFO_HTML}
<div class="block" style="margin:12px 16px 0;">
<div class="block-head">Контактные данные</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-top:4px;">
<div>

View File

@ -50,6 +50,7 @@
<script src="assets/cabinet.js?v=20260518j"></script>
<script src="assets/selfmeasure.js?v=20260518k"></script>
<script src="assets/orders.js?v=20260518l"></script>
<script src="assets/assembly_detail.js?v=20260518l"></script>
<script src="assets/app.js?v=20260518l"></script>
</body>
</html>