/* ============================================================ ExpeditorDashboard #/expeditor Act4Screen #/expeditor/act/:assemblyId Signature modes: telegram_otp | canvas ============================================================ */ const ExpeditorDashboard = (function () { "use strict"; const ROOM_PRESETS = [ { group: "Жилые", items: ["Гостиная","Спальня","Детская","Кабинет"] }, { group: "Кухня", items: ["Кухня","Кухня-гостиная","Столовая"] }, { group: "Санузел", items: ["Ванная","Санузел","Совмещённый"] }, { group: "Хранение", items: ["Прихожая","Коридор","Кладовая","Гардероб"] }, { group: "Другое", items: ["Балкон","Лоджия","Терраса","Доп. помещение"] }, ]; function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } function fmtDate(s) { if (!s) return ""; const d = new Date(s); return isNaN(d) ? s : d.toLocaleDateString("ru-RU",{day:"2-digit",month:"2-digit",year:"numeric"}); } async function _api(path, body) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 30000); try { const res = await fetch(BACKEND_URL + "/api/" + path, { method: "POST", signal: ctrl.signal, headers: {"Content-Type":"application/json"}, body: JSON.stringify(Object.assign({ initData: (typeof Platform !== "undefined" ? Platform.initData : ""), initDataUnsafe: (typeof Platform !== "undefined" ? Platform.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); } } // ── MAIN LIST ──────────────────────────────────────────────────────────── async function mount(container) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const nav = document.getElementById("bottom-nav"); if (nav) nav.remove(); const icons = window.ICONS || {}; const header = el( "
" + "" + "
Маршруты и акты
" + "
" ); header.querySelector(".podbor-back").addEventListener("click", () => { if (typeof haptic !== "undefined") haptic("impact"); history.back(); }); const screen = el("
"); container.appendChild(header); container.appendChild(screen); screen.innerHTML = "
"; try { const data = await _api("expeditor_inbox", {}); if (data.error) throw new Error(data.error); _renderList(screen, data.assemblies || []); } catch(e) { screen.innerHTML = "
Ошибка: " + escHtml(e.message) + "
"; } } function _groupByDate(items) { var today = new Date(); today.setHours(0,0,0,0); var tom = new Date(today); tom.setDate(tom.getDate()+1); var groups = {}, order = []; items.forEach(function(a) { var d = a.scheduled_at ? new Date(a.scheduled_at) : null; var label; if (!d || isNaN(d)) { label = "Без даты"; } else { var day = new Date(d); day.setHours(0,0,0,0); if (day.getTime() === today.getTime()) label = "Сегодня"; else if (day.getTime() === tom.getTime()) label = "Завтра"; else label = day.toLocaleDateString("ru-RU",{day:"2-digit",month:"long",weekday:"short"}); } if (!groups[label]) { groups[label] = []; order.push(label); } groups[label].push(a); }); return {groups: groups, order: order}; } function _renderList(screen, items) { screen.innerHTML = ""; if (!items.length) { screen.innerHTML = "
" + "
🚚
" + "
Маршрутов нет
" + "
Менеджер назначит вас на доставку
"; return; } const pending = items.filter(a => !a.is_signed); const done = items.filter(a => a.is_signed); if (pending.length) { screen.appendChild(el("
К приёмке (" + pending.length + ")
")); var gd = _groupByDate(pending); gd.order.forEach(function(label) { screen.appendChild(el('
📅 ' + label + '
')); gd.groups[label].forEach(function(a) { screen.appendChild(_card(a, false)); }); }); } if (done.length) { screen.appendChild(el("
Подписано (" + done.length + ")
")); done.forEach(a => screen.appendChild(_card(a, true))); } } function _card(a, signed) { const badge = signed ? "✅ Подписан" : "⏳ Ожидает"; const card = el( "
" + "
" + "
" + escHtml(a.client_name || "—") + "
" + badge + "
" + "
📍 " + escHtml(a.address || "адрес не указан") + "
" + (a.scheduled_at ? "
📅 " + fmtDate(a.scheduled_at) + "
" : "") + (signed && a.signed_at ? "
Подписан " + fmtDate(a.signed_at) + "
" : "") + "
" ); card.addEventListener("click", () => { if (typeof haptic !== "undefined") haptic("selection"); location.hash = "#/expeditor/act/" + a.id; }); return card; } // ── ACT4 SCREEN ────────────────────────────────────────────────────────── async function mountAct(container, assemblyId) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const nav = document.getElementById("bottom-nav"); if (nav) nav.remove(); const icons = window.ICONS || {}; const header = el( "
" + "" + "
Акт приёмки
" + "
" ); header.querySelector(".podbor-back").addEventListener("click", () => { if (typeof haptic !== "undefined") haptic("impact"); history.back(); }); const screen = el("
"); container.appendChild(header); container.appendChild(screen); screen.innerHTML = "
"; try { const data = await _api("act4_preview", {assembly_id: assemblyId}); if (data.error) throw new Error(data.error); _renderAct(screen, data, assemblyId); } catch(e) { screen.innerHTML = "
Ошибка: " + escHtml(e.message) + "
"; } } function _renderAct(screen, act, assemblyId) { screen.innerHTML = ""; // Client info screen.appendChild(el( "
" + "
" + escHtml(act.client_name || "—") + "
" + "
📍 " + escHtml(act.address || "—") + "
" + (act.client_phone ? "
📞 " + escHtml(act.client_phone) + "
" : "") + "
Акт № " + escHtml(act.act_num) + " · " + escHtml(act.act_date) + "
" + "
" )); // Already signed banner if (act.is_signed) { screen.appendChild(el( "
" + "
✅ Акт подписан
" + "
" + escHtml(act.signed_by_name) + " · " + fmtDate(act.signed_at) + " · " + escHtml(act.signed_via || "") + "
" + "
" )); } // Items list (existing) const itemsList = act.items || []; const itemsWrap = el("
"); if (itemsList.length) { itemsList.forEach(item => itemsWrap.appendChild(_itemCard(item))); } screen.appendChild(itemsWrap); // If not signed — show signature section if (!act.is_signed) { _renderSignatureSection(screen, assemblyId); } } function _itemCard(item) { const cond = item.condition === "damaged" ? "⚠ Повреждение" : "✓ OK"; return el( "
" + "
" + escHtml(item.name || "Позиция") + "
" + "
Кол-во: " + escHtml(String(item.qty || 1)) + "
" + "
" + cond + "
" ); } // ── SIGNATURE SECTION ──────────────────────────────────────────────────── function _renderSignatureSection(screen, assemblyId) { const wrap = el("
"); screen.appendChild(wrap); const tabs = el( "
" + "" + "" + "
" ); const otpPanel = el("
"); const canvasPanel = el("
"); _buildOtpPanel(otpPanel, assemblyId, wrap); _buildCanvasPanel(canvasPanel, assemblyId, wrap); tabs.querySelectorAll(".sig-tab").forEach(btn => { btn.addEventListener("click", () => { const tgt = btn.dataset.tab; tabs.querySelectorAll(".sig-tab").forEach(b => { const active = b.dataset.tab === tgt; b.style.background = active ? "var(--accent)" : "none"; b.style.color = active ? "#fff" : "var(--ink)"; b.style.borderColor = active ? "var(--accent)" : "var(--border)"; }); [otpPanel, canvasPanel].forEach(p => { p.style.display = p.dataset.panel === tgt ? "" : "none"; }); }); }); wrap.appendChild(tabs); wrap.appendChild(otpPanel); wrap.appendChild(canvasPanel); } function _buildOtpPanel(panel, assemblyId, wrap) { panel.innerHTML = ""; panel.appendChild(el( "
Бот пришлёт 6-значный код в этот чат. Введите его для подписи.
" )); const nameField = el( "
" + "
" ); panel.appendChild(nameField); const sendBtn = el( "" ); const codeSection = el("
"); const codeField = el( "
" + "
" ); const verifyBtn = el( "" ); const errEl = el("
"); codeSection.appendChild(codeField); codeSection.appendChild(verifyBtn); codeSection.appendChild(errEl); panel.appendChild(sendBtn); panel.appendChild(codeSection); sendBtn.addEventListener("click", async () => { sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; try { const data = await _api("act4_request_otp", {assembly_id: assemblyId}); if (data.error) { sendBtn.textContent = "Ошибка: " + data.error; sendBtn.disabled = false; return; } sendBtn.textContent = "✅ Код отправлен — проверьте Telegram"; codeSection.style.display = ""; panel.querySelector("#otpInput").focus(); } catch(e) { sendBtn.textContent = "Ошибка: " + e.message; sendBtn.disabled = false; } }); verifyBtn.addEventListener("click", async () => { const code = panel.querySelector("#otpInput").value.trim(); const name = panel.querySelector("#otpName").value.trim(); errEl.textContent = ""; if (code.length < 6) { errEl.textContent = "Введите 6-значный код"; return; } verifyBtn.disabled = true; verifyBtn.textContent = "Проверяем…"; try { const data = await _api("act4_verify_otp", {assembly_id: assemblyId, code, signed_by_name: name}); if (data.error) { const msgs = {invalid_code:"Неверный код",code_expired:"Код устарел, запросите новый",act_not_found:"Акт не найден"}; errEl.textContent = msgs[data.error] || data.error; verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; return; } if (typeof haptic !== "undefined") haptic("success"); wrap.innerHTML = "
Акт подписан
" + escHtml(data.signed_by_name) + "
"; } catch(e) { errEl.textContent = e.message; verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; } }); } function _buildCanvasPanel(panel, assemblyId, wrap) { panel.innerHTML = ""; panel.appendChild(el("
Нарисуйте подпись пальцем на экране.
")); const nameField = el( "
" + "
" ); panel.appendChild(nameField); const canvasWrap = el( "
" + "" + "" + "
" ); panel.appendChild(canvasWrap); const saveBtn = el( "" ); const errEl = el("
"); panel.appendChild(saveBtn); panel.appendChild(errEl); // Init canvas after DOM insertion (needs layout) requestAnimationFrame(() => { const canvas = panel.querySelector("#sigCanvas"); if (!canvas) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); ctx.lineWidth = 2.5; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#1a1a1a"; let drawing = false, lastX = 0, lastY = 0, hasStrokes = false; function pos(e) { const r = canvas.getBoundingClientRect(); const src = e.touches ? e.touches[0] : e; return [src.clientX - r.left, src.clientY - r.top]; } canvas.addEventListener("pointerdown", e => { drawing = true; [lastX, lastY] = pos(e); ctx.beginPath(); ctx.moveTo(lastX, lastY); e.preventDefault(); }); canvas.addEventListener("pointermove", e => { if (!drawing) return; const [x, y] = pos(e); ctx.lineTo(x, y); ctx.stroke(); lastX = x; lastY = y; hasStrokes = true; e.preventDefault(); }); canvas.addEventListener("pointerup", () => { drawing = false; }); canvas.addEventListener("pointerleave",() => { drawing = false; }); panel.querySelector("#clearCanvas").addEventListener("click", () => { ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); hasStrokes = false; }); saveBtn.addEventListener("click", async () => { if (!hasStrokes) { errEl.textContent = "Нарисуйте подпись"; return; } const name = panel.querySelector("#canvasName").value.trim(); const b64 = canvas.toDataURL("image/png").replace("data:image/png;base64,", ""); saveBtn.disabled = true; saveBtn.textContent = "Сохраняем…"; errEl.textContent = ""; try { const data = await _api("act4_save_signature", {assembly_id: assemblyId, signature_b64: b64, signed_by_name: name}); if (data.error) throw new Error(data.error); if (typeof haptic !== "undefined") haptic("success"); wrap.innerHTML = "
Акт подписан
" + escHtml(data.signed_by_name) + "
"; } catch(e) { errEl.textContent = e.message; saveBtn.disabled = false; saveBtn.textContent = "Подписать"; } }); }); } return { mount, mountAct }; })();