zov-tech/miniapp/assets/expeditor_dashboard.js
wasrusgen 02f8dba469 feat: expeditor cabinet, electronic signature (OTP+canvas), invoice room picker
New modules:
- expeditor_dashboard.js: route list (date-grouped) + act detail + signature screen
- invoice.js: 3-col chip room picker, 2500₽ base + 1000₽ extra logic
- act4.js, measurer_dashboard.js, finance_summary.js, client_timeline.js, feedback.js, staff_roster.js

Backend:
- /api/expeditor_inbox: filtered assembly list for expeditor role
- /api/act4_request_otp: 6-digit OTP via Telegram, 10-min expiry
- /api/act4_verify_otp: validates code, marks act as signed
- /api/act4_save_signature: saves base64 canvas signature
- Act4s sheet: added signature_b64, otp_code, otp_expires_at columns

Tests:
- tests/expeditor_scenarios.md: 11 manual test scenarios

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

392 lines
22 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.

/* ============================================================
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
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 class=\"podbor-header\">" +
"<button class=\"podbor-back\">" + (icons.arrow_left || "") + "</button>" +
"<div class=\"podbor-title\">Маршруты и акты</div>" +
"<div style=\"width:28px\"></div></header>"
);
header.querySelector(".podbor-back").addEventListener("click", () => {
if (typeof haptic !== "undefined") haptic("impact");
history.back();
});
const screen = el("<div class=\"podbor-screen\"></div>");
container.appendChild(header);
container.appendChild(screen);
screen.innerHTML = "<div class=\"loader-inline\"><div class=\"spinner\"></div></div>";
try {
const data = await _api("expeditor_inbox", {});
if (data.error) throw new Error(data.error);
_renderList(screen, data.assemblies || []);
} catch(e) {
screen.innerHTML = "<div class=\"error\" style=\"margin:16px;\">Ошибка: " + escHtml(e.message) + "</div>";
}
}
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 =
"<div style=\"text-align:center;padding:48px 20px;color:var(--muted);\">" +
"<div style=\"font-size:40px;margin-bottom:12px;\">🚚</div>" +
"<div style=\"font-weight:600;\">Маршрутов нет</div>" +
"<div style=\"font-size:13px;margin-top:6px;\">Менеджер назначит вас на доставку</div></div>";
return;
}
const pending = items.filter(a => !a.is_signed);
const done = items.filter(a => a.is_signed);
if (pending.length) {
screen.appendChild(el("<div style=\"padding:12px 16px 4px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;\">К приёмке (" + pending.length + ")</div>"));
var gd = _groupByDate(pending);
gd.order.forEach(function(label) {
screen.appendChild(el('<div style="padding:6px 16px 4px;font-size:12px;font-weight:600;color:var(--accent);">📅 ' + label + '</div>'));
gd.groups[label].forEach(function(a) { screen.appendChild(_card(a, false)); });
});
}
if (done.length) {
screen.appendChild(el("<div style=\"padding:16px 16px 4px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;\">Подписано (" + done.length + ")</div>"));
done.forEach(a => screen.appendChild(_card(a, true)));
}
}
function _card(a, signed) {
const badge = signed
? "<span style=\"font-size:11px;padding:2px 8px;background:#e8f5e9;color:#2e7d32;border-radius:20px;font-weight:600;\">✅ Подписан</span>"
: "<span style=\"font-size:11px;padding:2px 8px;background:#fff3e0;color:#e65100;border-radius:20px;font-weight:600;\">⏳ Ожидает</span>";
const card = el(
"<div style=\"margin:0 16px 10px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:14px;cursor:pointer;\">" +
"<div style=\"display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;\">" +
"<div style=\"font-size:14px;font-weight:700;\">" + escHtml(a.client_name || "—") + "</div>" +
badge + "</div>" +
"<div style=\"font-size:12px;color:var(--muted);margin-bottom:4px;\">📍 " + escHtml(a.address || "адрес не указан") + "</div>" +
(a.scheduled_at ? "<div style=\"font-size:11px;color:var(--muted);\">📅 " + fmtDate(a.scheduled_at) + "</div>" : "") +
(signed && a.signed_at ? "<div style=\"font-size:11px;color:#2e7d32;margin-top:4px;\">Подписан " + fmtDate(a.signed_at) + "</div>" : "") +
"</div>"
);
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 class=\"podbor-header\">" +
"<button class=\"podbor-back\">" + (icons.arrow_left || "") + "</button>" +
"<div class=\"podbor-title\">Акт приёмки</div>" +
"<div style=\"width:28px\"></div></header>"
);
header.querySelector(".podbor-back").addEventListener("click", () => {
if (typeof haptic !== "undefined") haptic("impact");
history.back();
});
const screen = el("<div class=\"podbor-screen\"></div>");
container.appendChild(header);
container.appendChild(screen);
screen.innerHTML = "<div class=\"loader-inline\"><div class=\"spinner\"></div></div>";
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 = "<div class=\"error\" style=\"margin:16px;\">Ошибка: " + escHtml(e.message) + "</div>";
}
}
function _renderAct(screen, act, assemblyId) {
screen.innerHTML = "";
// Client info
screen.appendChild(el(
"<div style=\"margin:12px 16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;\">" +
"<div style=\"font-size:14px;font-weight:700;margin-bottom:4px;\">" + escHtml(act.client_name || "—") + "</div>" +
"<div style=\"font-size:12px;color:var(--muted);\">📍 " + escHtml(act.address || "—") + "</div>" +
(act.client_phone ? "<div style=\"font-size:12px;color:var(--muted);margin-top:2px;\">📞 " + escHtml(act.client_phone) + "</div>" : "") +
"<div style=\"font-size:11px;color:var(--muted);margin-top:6px;\">Акт № " + escHtml(act.act_num) + " · " + escHtml(act.act_date) + "</div>" +
"</div>"
));
// Already signed banner
if (act.is_signed) {
screen.appendChild(el(
"<div style=\"margin:0 16px 12px;padding:12px 14px;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:12px;\">" +
"<div style=\"font-size:13px;font-weight:700;color:#2e7d32;margin-bottom:2px;\">✅ Акт подписан</div>" +
"<div style=\"font-size:12px;color:#388e3c;\">" + escHtml(act.signed_by_name) + " · " + fmtDate(act.signed_at) + " · " + escHtml(act.signed_via || "") + "</div>" +
"</div>"
));
}
// Items list (existing)
const itemsList = act.items || [];
const itemsWrap = el("<div style=\"margin:0 16px 10px;\"></div>");
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"
? "<span style=\"color:#e53935;font-weight:600;\">⚠ Повреждение</span>"
: "<span style=\"color:#43a047;\">✓ OK</span>";
return el(
"<div style=\"display:flex;justify-content:space-between;align-items:center;padding:10px 12px;margin-bottom:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;\">" +
"<div><div style=\"font-size:13px;font-weight:600;\">" + escHtml(item.name || "Позиция") + "</div>" +
"<div style=\"font-size:11px;color:var(--muted);\">Кол-во: " + escHtml(String(item.qty || 1)) + "</div></div>" +
"<div>" + cond + "</div></div>"
);
}
// ── SIGNATURE SECTION ────────────────────────────────────────────────────
function _renderSignatureSection(screen, assemblyId) {
const wrap = el("<div style=\"margin:0 16px 24px;\"></div>");
screen.appendChild(wrap);
const tabs = el(
"<div style=\"display:flex;gap:8px;margin-bottom:14px;\">" +
"<button data-tab=\"otp\" class=\"sig-tab sig-active\" style=\"flex:1;padding:10px;border-radius:10px;border:1.5px solid var(--accent);background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;\">📱 Код в Telegram</button>" +
"<button data-tab=\"canvas\" class=\"sig-tab\" style=\"flex:1;padding:10px;border-radius:10px;border:1.5px solid var(--border);background:none;color:var(--ink);font-size:13px;font-weight:600;cursor:pointer;\">✍️ Подпись рукой</button>" +
"</div>"
);
const otpPanel = el("<div class=\"sig-panel\" data-panel=\"otp\"></div>");
const canvasPanel = el("<div class=\"sig-panel\" data-panel=\"canvas\" style=\"display:none;\"></div>");
_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(
"<div style=\"font-size:12px;color:var(--muted);margin-bottom:10px;\">Бот пришлёт 6-значный код в этот чат. Введите его для подписи.</div>"
));
const nameField = el(
"<div style=\"margin-bottom:10px;\"><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Подписант (ФИО или должность)</label>" +
"<input id=\"otpName\" type=\"text\" placeholder=\"Иванов И.И.\" style=\"width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;background:var(--surface);\"></div>"
);
panel.appendChild(nameField);
const sendBtn = el(
"<button id=\"sendOtpBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-bottom:12px;\">Получить код</button>"
);
const codeSection = el("<div id=\"codeSection\" style=\"display:none;\"></div>");
const codeField = el(
"<div><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Введите код из Telegram</label>" +
"<input id=\"otpInput\" type=\"number\" inputmode=\"numeric\" maxlength=\"6\" placeholder=\"123456\" style=\"width:100%;box-sizing:border-box;padding:12px;border:1.5px solid var(--accent);border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;background:var(--surface);\"></div>"
);
const verifyBtn = el(
"<button id=\"verifyOtpBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-top:10px;\">Подтвердить</button>"
);
const errEl = el("<div id=\"otpErr\" style=\"color:#e53935;font-size:12px;margin-top:6px;\"></div>");
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 = "<div style=\"padding:16px;background:#e8f5e9;border:2px solid #43a047;border-radius:14px;text-align:center;\"><div style=\"font-size:22px;margin-bottom:8px;\">✅</div><div style=\"font-weight:700;color:#2e7d32;font-size:15px;\">Акт подписан</div><div style=\"font-size:12px;color:#388e3c;margin-top:4px;\">" + escHtml(data.signed_by_name) + "</div></div>";
} catch(e) { errEl.textContent = e.message; verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; }
});
}
function _buildCanvasPanel(panel, assemblyId, wrap) {
panel.innerHTML = "";
panel.appendChild(el("<div style=\"font-size:12px;color:var(--muted);margin-bottom:10px;\">Нарисуйте подпись пальцем на экране.</div>"));
const nameField = el(
"<div style=\"margin-bottom:10px;\"><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Подписант (ФИО или должность)</label>" +
"<input id=\"canvasName\" type=\"text\" placeholder=\"Иванов И.И.\" style=\"width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;background:var(--surface);\"></div>"
);
panel.appendChild(nameField);
const canvasWrap = el(
"<div style=\"border:1.5px solid var(--border);border-radius:10px;overflow:hidden;background:#fafafa;margin-bottom:10px;position:relative;\">" +
"<canvas id=\"sigCanvas\" style=\"width:100%;height:140px;display:block;touch-action:none;\"></canvas>" +
"<button id=\"clearCanvas\" style=\"position:absolute;top:6px;right:8px;font-size:11px;color:var(--muted);background:none;border:none;cursor:pointer;\">Очистить</button>" +
"</div>"
);
panel.appendChild(canvasWrap);
const saveBtn = el(
"<button id=\"saveCanvasBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;\">Подписать</button>"
);
const errEl = el("<div id=\"canvasErr\" style=\"color:#e53935;font-size:12px;margin-top:6px;\"></div>");
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 = "<div style=\"padding:16px;background:#e8f5e9;border:2px solid #43a047;border-radius:14px;text-align:center;\"><div style=\"font-size:22px;margin-bottom:8px;\">✅</div><div style=\"font-weight:700;color:#2e7d32;font-size:15px;\">Акт подписан</div><div style=\"font-size:12px;color:#388e3c;margin-top:4px;\">" + escHtml(data.signed_by_name) + "</div></div>";
} catch(e) { errEl.textContent = e.message; saveBtn.disabled = false; saveBtn.textContent = "Подписать"; }
});
});
}
return { mount, mountAct };
})();