/* ============================================================
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(
"" +
"📱 Код в Telegram " +
"✍️ Подпись рукой " +
"
"
);
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(
"Введите код из Telegram " +
"
"
);
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 };
})();