/* ============================================================ StaffClients — список клиентов для сборщика / замерщика #/master/clients ============================================================ */ const StaffClients = (function () { "use strict"; 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(iso) { if (!iso) return null; try { return new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric", }); } catch { return iso.slice(0, 10); } } async function _api(path, body = {}) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 20000); try { const res = await fetch(`${BACKEND_URL}/api/${path}`, { method: "POST", signal: ctrl.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.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); } } const ASM_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" }, }; const MEAS_STATUS = { new: { icon: "🆕", text: "Новый", color: "#8e8e8e" }, scheduled: { icon: "📅", text: "Назначен", color: "#2980B9" }, done: { icon: "✅", text: "Выполнен", color: "#27AE60" }, cancelled: { icon: "❌", text: "Отменён", color: "#C0392B" }, }; function _statusBadge(status, map) { const s = map[status] || { icon: "•", text: status, color: "#aaa" }; return `${s.icon} ${escHtml(s.text)}`; } /* ── Главный экран ─────────────────────────────────────────── */ async function mount(container) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); document.getElementById("bottom-nav")?.remove(); // Header const h = el(`
Мои клиенты
`); h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); // Фильтр const filterEl = el(`
`); const screen = el(`
`); container.appendChild(h); container.appendChild(filterEl); container.appendChild(screen); let currentFilter = "active"; const load = async (filter) => { currentFilter = filter; screen.innerHTML = `
`; try { const data = await _api("staff_clients", { filter }); if (data.error) { screen.innerHTML = `
${escHtml(data.error)}
`; return; } _render(screen, data, container); } catch (e) { screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; } }; filterEl.querySelectorAll(".sc-filter").forEach(btn => { btn.addEventListener("click", () => { filterEl.querySelectorAll(".sc-filter").forEach(b => { b.style.background = "var(--surface)"; b.style.color = "var(--muted)"; b.style.borderColor = "var(--border)"; }); btn.style.background = "var(--accent)"; btn.style.color = "#fff"; btn.style.borderColor = "var(--accent)"; haptic && haptic("selection"); load(btn.dataset.f); }); }); h.querySelector("#reloadBtn").addEventListener("click", () => { haptic && haptic("impact"); load(currentFilter); }); load("active"); } /* ── Рендер ─────────────────────────────────────────────────── */ function _render(screen, data, container) { screen.innerHTML = ""; const clients = data.clients || []; if (!clients.length) { screen.appendChild(el(`
📋
Клиентов нет
По выбранному фильтру ничего не найдено
`)); return; } // Роль-бейдж в шапке const roles = []; if (data.is_assembler) roles.push("сборщик"); if (data.is_measurer) roles.push("замерщик"); if (roles.length) { screen.appendChild(el(`
${escHtml(roles.join(" · "))} · ${clients.length} клиентов
`)); } clients.forEach(c => { const asmCount = c.assemblies.length; const measCount = c.measurements.length; // Ближайшая дата const dates = [ ...c.assemblies.map(a => a.scheduled_at), ...c.measurements.map(m => m.scheduled_at), ].filter(Boolean).sort(); const nearestDate = dates[0] || null; // Статусы для превью const asmStatuses = c.assemblies.map(a => a.status); const measStatuses = c.measurements.map(m => m.status); const card = el(`
${escHtml(c.client_name || "Без имени")}
${c.client_phone ? `
${escHtml(c.client_phone)}
` : ""}
${nearestDate ? `
${escHtml(fmtDate(nearestDate))}
` : ""}
${asmCount ? c.assemblies.map(a => ` ${_statusBadge(a.status, ASM_STATUS)} ${a.address ? `${escHtml(a.address.split(",")[0])}` : ""} `).join("") : ""} ${measCount ? c.measurements.map(m => ` 📐 ${_statusBadge(m.status, MEAS_STATUS).replace(/<[^>]+>/g,'').trim()} `).join("") : ""}
`); card.addEventListener("click", () => { haptic && haptic("impact"); _openClientDetail(container, c, data); }); screen.appendChild(card); }); screen.appendChild(el(`
`)); } /* ── Детальная карточка клиента ────────────────────────────── */ function _openClientDetail(container, c, listData) { container.innerHTML = ""; const h = el(`
${escHtml(c.client_name || "Клиент")}
`); h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); mount(container); }); const screen = el(`
`); container.appendChild(h); container.appendChild(screen); // Контакты const phone = c.client_phone || ""; screen.appendChild(el(`
${escHtml(c.client_name || "Без имени")}
${phone ? ` 📞 ${escHtml(phone)} ` : `
Телефон не указан
`}
`)); // Сборки if (c.assemblies.length) { screen.appendChild(el(`
🔨 Сборки · ${c.assemblies.length}
`)); c.assemblies.forEach(a => { const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" }; const needsConfirm = !a.scheduled_at && !a.confirmed_at && a.status === "created"; const confirmDeadline = a.confirm_by ? new Date(a.confirm_by) : null; const isOverdue = confirmDeadline && confirmDeadline < new Date(); const asmCard = el(`
${s.icon} ${escHtml(s.text)}
${a.address ? `
${escHtml(a.address)}
` : ""} ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0,80))}
` : ""} ${a.date_range ? `
📅 ${escHtml(a.date_range)}
` : ""}
${a.scheduled_at ? `
${escHtml(fmtDate(a.scheduled_at))}
` : ""} ${a.confirmed_at ? `
✅ Согласовано
` : ""} ${a.signed_by_name ? `
✍️ Подписан
` : ""}
${needsConfirm ? `
${confirmDeadline && !isOverdue ? `
⏱ Осталось: —
` : isOverdue ? `
⚠️ Срок подтверждения истёк
` : ""}
` : ""}
`); // Переход в детальный экран по тапу asmCard.querySelector(".asm-tap").addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/assembly/${a.id}`; }); // Кнопка подтверждения const confirmBtn = asmCard.querySelector(".confirm-date-btn"); if (confirmBtn) { confirmBtn.addEventListener("click", (e) => { e.stopPropagation(); haptic && haptic("impact"); _openScheduleOverlay(a.id, "assembly", c.client_name, () => { // После подтверждения перезагружаем список mount(container); }); }); } // Таймер обратного отсчёта if (confirmDeadline && !isOverdue) { const timerEl = asmCard.querySelector(`#timer-${a.id}`); if (timerEl) { const tick = () => { const diff = confirmDeadline - new Date(); if (diff <= 0) { timerEl.textContent = "⚠️ Срок истёк"; return; } const h = Math.floor(diff / 3600000); const m = Math.floor((diff % 3600000) / 60000); const s2 = Math.floor((diff % 60000) / 1000); timerEl.textContent = `⏱ Осталось: ${h}ч ${m}м ${s2}с`; }; tick(); const iv = setInterval(tick, 1000); // Останавливаем таймер при уходе со страницы const obs = new MutationObserver(() => { if (!document.contains(timerEl)) { clearInterval(iv); obs.disconnect(); } }); obs.observe(document.body, { childList: true, subtree: true }); } } screen.appendChild(asmCard); }); } // Замеры if (c.measurements.length) { screen.appendChild(el(`
📐 Замеры · ${c.measurements.length}
`)); c.measurements.forEach(m => { const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" }; const needsConfirmM = !m.scheduled_at && m.status !== "done" && m.status !== "cancelled"; const mCard = el(`
${s.icon} ${escHtml(s.text)}
${m.address ? `
${escHtml(m.address)}
` : ""} ${m.zamer_no ? `
Замер №${escHtml(m.zamer_no)}
` : ""} ${m.preferred_date ? `
📅 Клиент: ${escHtml(m.preferred_date)}
` : ""}
${m.scheduled_at ? `
${escHtml(fmtDate(m.scheduled_at))}
` : ""}
${needsConfirmM ? ` ` : ""}
`); const measConfirmBtn = mCard.querySelector(".confirm-meas-btn"); if (measConfirmBtn) { measConfirmBtn.addEventListener("click", () => { haptic && haptic("impact"); _openScheduleOverlay(m.id, "measurement", c.client_name, () => mount(container)); }); } screen.appendChild(mCard); }); } screen.appendChild(el(`
`)); } /* ── Оверлей выбора даты/времени ───────────────────────────── */ function _openScheduleOverlay(itemId, type, clientName, onSuccess) { document.getElementById("schedule-overlay")?.remove(); // Минимальная дата — сегодня const todayISO = new Date().toISOString().slice(0, 16); const overlay = document.createElement("div"); overlay.id = "schedule-overlay"; overlay.style.cssText = ` position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000; display:flex;align-items:flex-end;justify-content:center; `; overlay.innerHTML = `
${type === "assembly" ? "📅 Дата сборки" : "📅 Дата замера"}
${escHtml(clientName)}
`; document.body.appendChild(overlay); overlay.querySelector("#sc-cancel").addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); overlay.querySelector("#sc-confirm").addEventListener("click", async () => { const dt = overlay.querySelector("#sc-datetime").value; const note = overlay.querySelector("#sc-note").value.trim(); const errEl = overlay.querySelector("#sc-err"); if (!dt) { errEl.textContent = "Выберите дату и время"; return; } const btn = overlay.querySelector("#sc-confirm"); btn.disabled = true; btn.textContent = "Сохраняем…"; errEl.textContent = ""; try { const path = type === "assembly" ? "assembly_schedule" : "measurement_schedule"; const idKey = type === "assembly" ? "assembly_id" : "measurement_id"; const res = await _api(path, { [idKey]: itemId, scheduled_at: dt, note }); if (res.error) throw new Error(res.error); haptic && haptic("success"); overlay.remove(); if (typeof onSuccess === "function") onSuccess(); } catch (e) { btn.disabled = false; btn.textContent = "✅ Подтвердить"; errEl.textContent = "Ошибка: " + e.message; } }); } return { mount }; })();