zov-tech/miniapp/assets/assembly_detail.js
wasrusgen 3e7ae7764a feat(assembly): configurable assembly rates + admin panel
Backend:
- Sheet "Assembly_Rates": rules by assembler_tg_id + scope
- _resolve_rates(): priority matching (specific > wildcard)
- Default: client 10%, assembler 9% (1% margin)
- _calc_assembly_prices(): role-aware field set in detail API
- Endpoints: assembly_rates_list, assembly_rate_save, assembly_rate_delete
- Cache TTL 120s, auto-seeded default rule on first run

Frontend:
- assembly_detail.js: shows client rate %, assembler payout % (role-aware)
- admin_rates.js: list/add/edit/deactivate rules with live margin preview
- app.js: route #/admin/rates + "Ставки сборки" button in manager dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:20:03 +03:00

227 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
Детальная карточка сборки — #/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>`;
// Финансовый блок — ставки из backend (настраиваются в админке)
const _kp = data.kitchen_price ? Number(data.kitchen_price) : 0;
const _cr = data.client_rate_pct || 10;
const _ar = data.assembler_rate_pct || 9;
const _cp = data.assembly_price_for_client != null
? Number(data.assembly_price_for_client)
: (_kp ? Math.round(_kp * _cr / 100) : 0);
const _ap = data.assembler_payout != null
? Number(data.assembler_payout)
: null;
const _priceRows = _kp ? `
${row("Стоимость кухни", _kp.toLocaleString("ru-RU") + " ₽")}
${row("Стоимость сборки (" + _cr + "%)", _cp.toLocaleString("ru-RU") + " ₽")}
${_ap != null ? row("Ваш заработок (" + _ar + "%)", Math.round(_ap).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""}
` : "";
// Основные данные
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)}
${_priceRows}
${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 VIA_LABELS = {
canvas: "✍️ Подпись пальцем",
code: "📱 Код подтверждения",
proxy: "👤 Представитель",
absent: "🚫 Без подписи",
};
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;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);margin-bottom:6px;">
${escHtml(VIA_LABELS[data.signed_via] || "Принято")}
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(data.signed_by_name)}</div>
<div style="font-size:12px;color:var(--muted);">${escHtml(fmtDate(data.signed_at) || "")}</div>
</div>
${data.signed_by_phone ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(data.signed_by_phone)}</div>` : ""}
</div>` : `<div id="sr-sign-btn-wrap" style="margin:12px 16px 0;"></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>`;
// Кнопка «Подписать акт» — только если ещё не подписано
if (!data.signed_by_name) {
const btnWrap = screen.querySelector("#sr-sign-btn-wrap");
if (btnWrap) {
const signBtn = document.createElement("button");
signBtn.className = "btn-primary";
signBtn.style.cssText = "width:100%;font-size:15px;padding:13px;";
signBtn.textContent = "✍️ Подписать акт приёмки";
signBtn.addEventListener("click", () => {
haptic && haptic("impact");
if (typeof SignRequest !== "undefined") {
SignRequest.open(data.id, {
clientName: data.client_name || "",
clientTgId: data.client_tg_id || "",
onSuccess: () => {
// Перерисовываем экран после подписания
mount(container, assemblyId);
},
});
}
});
btnWrap.appendChild(signBtn);
}
}
} catch (e) {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
}
}
return { mount };
})();