/* ============================================================ Система оценок — виджет + экран #/feedback/my Используется в: assembly_detail.js, app.js (замерщик, менеджер) ============================================================ */ const FeedbackModule = (function () { function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } 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(`HTTP ${res.status}`); return await res.json(); } catch (e) { if (e.name === "AbortError") throw new Error("Сервер не отвечает"); throw e; } finally { clearTimeout(t); } } // ── Отрисовка звёздочек (только чтение) ──────────────────────── function starsHtml(avg, size) { if (avg == null) return ""; const sz = size || 14; const full = Math.floor(avg); const half = (avg - full) >= 0.4 ? 1 : 0; const empty = 5 - full - half; return ( "★".repeat(full) + (half ? "½" : "") + "☆".repeat(empty) ).split("").map((c, i) => { const col = i < full ? "#F39C12" : (c === "½" ? "#F39C12" : "#ddd"); return `${c === "½" ? "★" : c}`; }).join(""); } // ── Интерактивный виджет звёзд ───────────────────────────────── // Возвращает {el, getValue()} function createStarWidget(label, sublabel) { const wrap = document.createElement("div"); wrap.style.cssText = "margin-bottom:14px;"; wrap.innerHTML = `
${escHtml(label)}
${sublabel ? `
${escHtml(sublabel)}
` : ""}
${[1,2,3,4,5].map(i => ` `).join("")}
`; const row = wrap.querySelector(".fb-stars"); const btns = [...row.querySelectorAll("button")]; let selected = 0; function paint(n) { btns.forEach((b, i) => { b.style.color = i < n ? "#F39C12" : "#ddd"; }); } btns.forEach((btn, idx) => { btn.addEventListener("mouseenter", () => paint(idx + 1)); btn.addEventListener("mouseleave", () => paint(selected)); btn.addEventListener("click", () => { selected = idx + 1; row.dataset.value = selected; haptic && haptic("impact"); paint(selected); }); }); return { el: wrap, getValue: () => selected, isValid: () => selected >= 1, }; } // ── Форма оценки после сборки (для клиента) ──────────────────── // container — DOM-элемент куда рендерить // config = { assemblerName, assemblerTgId, managerName, managerTgId, // assemblyId, onSubmit() } function mountAssemblyFeedback(container, cfg) { container.innerHTML = ""; container.style.cssText = "margin:12px 16px 0;padding:14px;background:var(--surface);" + "border:2px solid var(--accent);border-radius:14px;"; const title = document.createElement("div"); title.style.cssText = "font-size:14px;font-weight:700;color:var(--ink);margin-bottom:2px;"; title.textContent = "⭐ Оцените нашу работу"; const sub = document.createElement("div"); sub.style.cssText = "font-size:12px;color:var(--muted);margin-bottom:12px;"; sub.textContent = "Займёт 10 секунд — помогает нам становиться лучше"; container.appendChild(title); container.appendChild(sub); const wAsm = cfg.assemblerName ? createStarWidget(`👷 ${cfg.assemblerName}`, "Качество сборки") : null; const wMgr = cfg.managerName ? createStarWidget(`🗂 ${cfg.managerName}`, "Работа менеджера") : null; const wSvc = createStarWidget("🏠 Сервис в целом", "Насколько довольны компанией?"); if (wAsm) container.appendChild(wAsm.el); if (wMgr) container.appendChild(wMgr.el); container.appendChild(wSvc.el); // Комментарий const cmtWrap = document.createElement("div"); cmtWrap.style.cssText = "margin-bottom:10px;"; cmtWrap.innerHTML = ` `; container.appendChild(cmtWrap); const sendBtn = document.createElement("button"); sendBtn.className = "btn-primary"; sendBtn.style.cssText = "width:100%;font-size:14px;padding:11px;"; sendBtn.textContent = "Отправить оценку"; const statusEl = document.createElement("div"); statusEl.style.cssText = "font-size:12px;color:var(--muted);min-height:16px;margin-top:6px;"; container.appendChild(sendBtn); container.appendChild(statusEl); sendBtn.addEventListener("click", async () => { // Нужна хотя бы одна оценка const hasAny = (wAsm && wAsm.isValid()) || (wMgr && wMgr.isValid()) || wSvc.isValid(); if (!hasAny) { statusEl.textContent = "Поставьте хотя бы одну звезду"; return; } haptic && haptic("impact"); sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; const comment = container.querySelector("#fb-comment")?.value.trim() || ""; const ratings = []; if (wAsm && wAsm.isValid()) { ratings.push({ target_tg_id: cfg.assemblerTgId, target_role: "assembler", stars: wAsm.getValue() }); } if (wMgr && wMgr.isValid()) { ratings.push({ target_tg_id: cfg.managerTgId, target_role: "manager", stars: wMgr.getValue() }); } if (wSvc.isValid()) { ratings.push({ target_role: "service", stars: wSvc.getValue(), comment }); } try { const res = await _api("feedback_submit", { ref_id: cfg.assemblyId, ref_type: "assembly", ratings, }); if (res.ok) { haptic && haptic("success"); container.innerHTML = `
🙏
Спасибо за оценку!
Ваш отзыв помогает нам работать лучше
`; if (cfg.onSubmit) cfg.onSubmit(); } else { statusEl.textContent = res.msg || res.error || "Ошибка"; sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку"; } } catch (e) { statusEl.textContent = e.message; sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку"; } }); } // ── Форма оценки замерщиком → менеджера (после завершения замера) ── // container — куда рендерить // cfg = { managerName, managerTgId, measurementId, onSubmit() } function mountMeasurerFeedback(container, cfg) { container.innerHTML = ""; container.style.cssText = "margin:12px 0 0;padding:12px;background:var(--surface);" + "border:1px solid var(--border);border-radius:12px;"; const title = document.createElement("div"); title.style.cssText = "font-size:13px;font-weight:700;color:var(--ink);margin-bottom:8px;"; title.textContent = "💬 Оценка заявки от менеджера"; container.appendChild(title); const w = createStarWidget( `🗂 ${cfg.managerName || "Менеджер"}`, "Насколько полно была подготовлена заявка?" ); container.appendChild(w.el); const sendBtn = document.createElement("button"); sendBtn.className = "btn-secondary"; sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;"; sendBtn.textContent = "Оценить"; const statusEl = document.createElement("div"); statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;"; container.appendChild(sendBtn); container.appendChild(statusEl); sendBtn.addEventListener("click", async () => { if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; } haptic && haptic("impact"); sendBtn.disabled = true; sendBtn.textContent = "…"; try { const res = await _api("feedback_submit", { ref_id: cfg.measurementId, ref_type: "measurement", ratings: [{ target_tg_id: cfg.managerTgId, target_role: "manager", stars: w.getValue() }], }); if (res.ok) { container.innerHTML = `
✅ Оценка отправлена
`; if (cfg.onSubmit) cfg.onSubmit(); } else { statusEl.textContent = res.error || "Ошибка"; sendBtn.disabled = false; sendBtn.textContent = "Оценить"; } } catch (e) { statusEl.textContent = e.message; sendBtn.disabled = false; sendBtn.textContent = "Оценить"; } }); } // ── Форма оценки менеджером → замерщика ──────────────────────── // cfg = { measurerName, measurerTgId, measurementId, onSubmit() } function mountManagerFeedback(container, cfg) { container.innerHTML = ""; container.style.cssText = "margin:8px 0 0;padding:12px;background:var(--surface);" + "border:1px solid var(--border);border-radius:12px;"; const w = createStarWidget( `📐 ${cfg.measurerName || "Замерщик"}`, "Качество замера и документации" ); container.appendChild(w.el); const sendBtn = document.createElement("button"); sendBtn.className = "btn-secondary"; sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;"; sendBtn.textContent = "Оценить замерщика"; const statusEl = document.createElement("div"); statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;"; container.appendChild(sendBtn); container.appendChild(statusEl); sendBtn.addEventListener("click", async () => { if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; } haptic && haptic("impact"); sendBtn.disabled = true; sendBtn.textContent = "…"; try { const res = await _api("feedback_submit", { ref_id: cfg.measurementId, ref_type: "measurement", ratings: [{ target_tg_id: cfg.measurerTgId, target_role: "measurer", stars: w.getValue() }], }); if (res.ok) { container.innerHTML = `
✅ Оценка отправлена
`; if (cfg.onSubmit) cfg.onSubmit(); } else { statusEl.textContent = res.error || "Ошибка"; sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика"; } } catch (e) { statusEl.textContent = e.message; sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика"; } }); } // ── Экран «Мои оценки» — #/feedback/my ───────────────────────── function mountMyScreen(container) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); const h = document.createElement("header"); h.className = "podbor-header"; h.innerHTML = `
Мои оценки
`; h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); container.appendChild(h); const screen = document.createElement("div"); screen.className = "podbor-screen"; screen.style.cssText = "padding:0 0 48px;"; screen.innerHTML = `
`; container.appendChild(screen); _api("feedback_my").then(data => { if (data.error) { screen.innerHTML = `
${escHtml(data.error)}
`; return; } screen.innerHTML = ""; if (!data.total) { screen.innerHTML = `
Оценок пока нет.
Они появятся после завершения работ.
`; return; } // Общий балл (среднее по всем ролям) const allVals = (data.aggregated || []).map(a => a.avg); const overall = allVals.length ? (allVals.reduce((s, v) => s + v, 0) / allVals.length).toFixed(1) : null; const heroEl = document.createElement("div"); heroEl.style.cssText = "padding:20px 16px;text-align:center;border-bottom:1px solid var(--border);"; heroEl.innerHTML = `
${overall || "—"}
${starsHtml(parseFloat(overall), 18)}
${data.total} оценок
`; screen.appendChild(heroEl); // По ролям for (const agg of (data.aggregated || [])) { const rowEl = document.createElement("div"); rowEl.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);" + "display:flex;justify-content:space-between;align-items:center;"; rowEl.innerHTML = `
${escHtml(agg.label)}
${agg.count} оценок
${agg.avg}
${starsHtml(agg.avg, 13)}
`; screen.appendChild(rowEl); } // Комментарии if (data.comments && data.comments.length) { const cmtHead = document.createElement("div"); cmtHead.className = "section-head"; cmtHead.style.marginTop = "16px"; cmtHead.innerHTML = `Комментарии`; screen.appendChild(cmtHead); for (const c of data.comments) { const cEl = document.createElement("div"); cEl.style.cssText = "margin:0 16px 8px;padding:10px 12px;background:var(--surface);" + "border:1px solid var(--border);border-radius:10px;"; cEl.innerHTML = `
${escHtml(c.role || "Клиент")} ${"★".repeat(parseInt(c.stars)||0)}
${escHtml(c.comment)}
`; screen.appendChild(cEl); } } }).catch(e => { screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; }); } return { starsHtml, createStarWidget, mountAssemblyFeedback, mountMeasurerFeedback, mountManagerFeedback, mountMyScreen, }; })();