// ЗОВ MiniApp — главный скрипт. v20260519e // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. // tg и Platform определены в platform.js (загружается первым). // Cloudflare Quick Tunnel → VPS FastAPI backend (GigaChat). // Временный URL — пока wasrusgen1.pro в verification-hold; затем переключим на https://api.wasrusgen1.pro // Позволяет переключить бэкенд через ?backend=https://staging.api.wasrusgen1.pro const BACKEND_URL = new URLSearchParams(window.location.search).get("backend") || "https://api.wasrusgen1.pro"; const app = document.getElementById("app"); /* ----------------- Theme / variant helpers ----------------- */ const THEME_KEY = "zov_variant"; const THEMES = [ { id: "", name: "ЗОВ", dotA: "#003E7E", dotB: "#76BD22", outline: false }, { id: "b", name: "Foundry", dotA: "#15140F", dotB: "#B68A1A", outline: false }, { id: "c", name: "Boardroom",dotA: "#0E2A2E", dotB: "#D08A55", outline: false }, { id: "d", name: "Atelier", dotA: "#2E5266", dotB: "#E9EBEF", outline: true }, ]; function applyVariant(id) { const html = document.documentElement; if (id) { html.setAttribute("data-variant", id); } else { html.removeAttribute("data-variant"); } try { localStorage.setItem(THEME_KEY, id); } catch(e) {} } function savedVariant() { try { return localStorage.getItem(THEME_KEY) ?? ""; } catch(e) { return ""; } } /* ----------------- Platform setup ----------------- */ function setupTelegram() { document.documentElement.setAttribute("data-theme", Platform.colorScheme); applyVariant(savedVariant()); Platform.ready(); Platform.expand(); Platform.onThemeChange(() => { document.documentElement.setAttribute("data-theme", Platform.colorScheme); }); Platform.enableClosingConfirmation(); } function haptic(type = "selection") { Platform.haptic(type); } /* ----------------- Palette switcher UI ----------------- */ function renderPaletteSwitcher() { const current = savedVariant(); const wrap = el(`
`); // Маленький ярлык слева const lbl = el(`Тема`); wrap.appendChild(lbl); THEMES.forEach(t => { const btn = el(` `); btn.addEventListener("click", () => { haptic(); applyVariant(t.id); // Перерисовываем все кнопки wrap.querySelectorAll(".ps-btn").forEach((b, i) => { b.classList.toggle("active", THEMES[i].id === t.id); }); }); wrap.appendChild(btn); }); return wrap; } /* ----------------- Data ----------------- */ async function fetchMe() { if (!BACKEND_URL) { // dev-режим без backend — мок для просмотра вёрстки return { role: "manager", user: { full_name: "Руслан Васильев", salon: "ЗОВ Москва", avatar_initial: "Р", }, status: "active", status_until: "12.08.2026", }; } // Apps Script Web App: путь через query-параметр. // Заголовок Content-Type НЕ ставим — иначе браузер шлёт CORS preflight, // который Apps Script не обрабатывает. Без заголовка fetch использует // text/plain — Apps Script всё равно парсит body как JSON. // Роль приходит в URL (?role=manager|client) — её бот подставляет в WebApp-кнопку const urlParams = new URLSearchParams(window.location.search); const explicitRole = urlParams.get("role"); const res = await fetch(`${BACKEND_URL}/api/me`, { method: "POST", body: JSON.stringify({ initData: Platform.initData, // Fallback для Telegram Desktop side-panel где initData может приходить пустым. // Backend проверит подпись initData первым; если её нет — упадёт сюда. UNSAFE! initDataUnsafe: Platform.initDataUnsafe, startParam: Platform.startParam, role: explicitRole, }), }); if (!res.ok) throw new Error("backend HTTP " + res.status); return res.json(); } /* ----------------- Helpers ----------------- */ function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } function statusLabel(s) { return ({ active: "Активен", lapsed: "Ограничен", grace: "Грейс", })[s] || s; } function getInitial(name) { return (name || "").trim().slice(0, 1).toUpperCase() || "?"; } /* ----------------- Renders ----------------- */ function renderManager(me) { // Новый главный экран — «утро менеджера» return renderManagerHome(me); } function timeOfDay(date = new Date()) { const h = date.getHours(); if (h >= 5 && h < 12) return "Доброе утро"; if (h >= 12 && h < 18) return "Добрый день"; if (h >= 18 && h < 23) return "Добрый вечер"; return "Доброй ночи"; } function pluralRu(n, forms) { // forms = ["замер", "замера", "замеров"] const mod10 = n % 10, mod100 = n % 100; if (mod100 >= 11 && mod100 <= 14) return forms[2]; if (mod10 === 1) return forms[0]; if (mod10 >= 2 && mod10 <= 4) return forms[1]; return forms[2]; } async function renderManagerHome(me) { const firstName = (me.user?.full_name || "").split(/\s+/)[0] || "Менеджер"; app.innerHTML = ""; document.body.classList.add("has-bottom-nav"); // Palette (theme) switcher — вверху экрана app.appendChild(renderPaletteSwitcher()); // Greeting + bell (placeholder) const greetingEl = el(`
${timeOfDay()}
${firstName},
смотрим день…
`); app.appendChild(greetingEl); // Контейнер для «Сегодня» — наполнится после загрузки const todayContainer = el(`
`); app.appendChild(todayContainer); // Quick actions const quickActions = [ { icon: "user", title: "Клиенты", subtitle: "История + хронология", href: "#/clients" }, { icon: "clipboard", title: "Заказы", subtitle: "Сборки + заявки", href: "#/assembly" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" }, { icon: "folder", title: "Аналитика", subtitle: "Занятость сборщиков", href: "#/admin/assembler-analytics" }, { icon: "user", title: "Команда", subtitle: "Нагрузка + статусы", href: "#/admin/staff" }, { icon: "wallet", title: "Финансы", subtitle: "Выручка · маржа · выплаты", href: "#/admin/finance" }, { icon: "star", title: "Мои оценки", subtitle: "Рейтинг · отзывы", href: "#/feedback/my" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); quickActions.forEach(qa => { const card = el(` `); card.addEventListener("click", () => { haptic("impact"); if (qa.href) location.hash = qa.href; else Platform.showAlert(`«${qa.title}» — скоро`); }); grid.appendChild(card); }); app.appendChild(grid); // Активные проекты — будет наполняться позже из реальных данных const projectsContainer = el(`
`); app.appendChild(projectsContainer); // Сборки в работе (под активными проектами) const assembliesContainer = el(`
`); app.appendChild(assembliesContainer); // Контейнер для отгрузок с завода (под активными проектами) const shipmentsContainer = el(`
`); app.appendChild(shipmentsContainer); // Контейнер для поступлений на склад СПб const arrivalsContainer = el(`
`); app.appendChild(arrivalsContainer); renderBottomNav("home", { unreadChats: 0 }); // Контейнер для карточек «Замер готов — что делать с подбором?» const pendingContainer = el(`
`); app.insertBefore(pendingContainer, todayContainer); // Параллельно грузим реальные данные (измерения + pending — критичные) // Складские данные грузим отдельно, чтобы ошибка Drive не ломала весь дашборд try { const authBody = { initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }; const [resM, resP] = await Promise.all([ fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }), fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }), ]); const data = await resM.json(); const pendingData = await resP.json(); renderManagerPending(pendingContainer, pendingData.pending || []); renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl); renderManagerProjects(projectsContainer, data.measurements || []); // Складские данные + сборки — не критичны; ошибка не ломает дашборд const authBodyStr = JSON.stringify(authBody); Promise.all([ fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), fetch(`${BACKEND_URL}/api/assembly_list`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})), ]).then(([shipmentsData, arrivalsData, assemblyData]) => { renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода"); renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб"); renderManagerAssemblies(assembliesContainer, assemblyData.assemblies || []); }).catch(() => { /* тихо — дашборд уже отрисован */ }); } catch (e) { todayContainer.innerHTML = `
Не удалось загрузить данные: ${escHtml(e.message)}
`; } } /* ----------------- Менеджер: карточки «Замер готов — подбор?» ----------------- */ function renderManagerPending(container, pending) { container.innerHTML = ""; if (!pending.length) return; container.appendChild(el(`
✅ Замеры готовы · ${pending.length}
`)); for (const p of pending) { const isLater = p.decision === "later"; const card = el(`
${escHtml(p.client_name || "Без имени")}
Замер выполнен · ${escHtml(p.address || "адрес не указан")}
${isLater ? "Снова: " : ""}Клиенту потребуется помощь с подбором техники?
`); card.querySelectorAll("button[data-act]").forEach(btn => { btn.addEventListener("click", () => handlePodborDecision(p, btn.dataset.act, card)); }); container.appendChild(card); } } async function handlePodborDecision(item, act, card) { const decisionMap = { yes: "needed", no: "not_needed", later: "later" }; const decision = decisionMap[act]; if (!decision) return; const resultEl = card.querySelector(".pending-result"); if (resultEl) resultEl.textContent = "Сохраняем..."; card.querySelectorAll("button").forEach(b => b.disabled = true); try { const res = await fetch(`${BACKEND_URL}/api/measurement_decision`, { method: "POST", body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe, measurement_id: item.id, decision, }), }); const data = await res.json(); if (data.error) { if (resultEl) resultEl.innerHTML = `Ошибка: ${escHtml(data.error)}`; card.querySelectorAll("button").forEach(b => b.disabled = false); return; } haptic && haptic("success"); if (decision === "needed") { // Переходим в подбор техники с pre-fill из клиента sessionStorage.setItem("prefillClient", JSON.stringify({ name: item.client_name, phone: item.client_phone, })); location.hash = `#/podbor?client_name=${encodeURIComponent(item.client_name || "")}&client_phone=${encodeURIComponent(item.client_phone || "")}`; } else { // Анимируем удаление карточки card.style.transition = "opacity 0.25s, transform 0.25s"; card.style.opacity = "0"; card.style.transform = "translateX(20px)"; setTimeout(() => card.remove(), 250); } } catch (e) { if (resultEl) resultEl.innerHTML = `Сеть: ${escHtml(e.message)}`; card.querySelectorAll("button").forEach(b => b.disabled = false); } } function renderManagerToday(container, measurements, firstName, greetingEl) { const today = _startOfDay(new Date()); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); // Сегодня = scheduled_at сегодня и не completed const todayEvents = []; const overdueEvents = []; const noDateEvents = []; for (const m of measurements) { if (m.status === "completed") continue; if (m.scheduled_at) { const d = new Date(m.scheduled_at); if (_startOfDay(d).getTime() === today.getTime()) { todayEvents.push(m); } else if (d < new Date()) { overdueEvents.push(m); } } else if (m.status === "requested") { // Заявка без даты — нужно подсказать замерщику noDateEvents.push(m); } } todayEvents.sort((a, b) => (a.scheduled_at || "").localeCompare(b.scheduled_at || "")); // Обновляем приветствие const cnt = todayEvents.length; let tail; if (cnt === 0) { tail = overdueEvents.length ? `${overdueEvents.length} ${pluralRu(overdueEvents.length, ["просрочка", "просрочки", "просрочек"])}` : "ничего на сегодня"; } else { const word = pluralRu(cnt, ["замер", "замера", "замеров"]); tail = `${cnt === 1 ? "один" : cnt} ${word} сегодня`; } const headline = greetingEl.querySelector("#greetingHeadline"); if (headline) headline.innerHTML = `${escHtml(firstName)},
${escHtml(tail)}`; container.innerHTML = ""; // HERO — первое событие сегодня if (todayEvents.length > 0) { const m = todayEvents[0]; const d = new Date(m.scheduled_at); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, ""); const hero = el(`
На сегодня${hh}:${mi} ЗАМЕР
${escHtml(m.client_name || "Без имени")}
${escHtml(m.address || "адрес не указан")}
${phoneClean ? `${ICONS.phone || "📞"}` : ""}
`); hero.querySelector("#heroOpen").addEventListener("click", () => { haptic("impact"); location.hash = `#/clients/measurement/${m.id}`; }); container.appendChild(hero); } // Срочно: просрочки if (overdueEvents.length > 0) { container.appendChild(el(`
⚠️ Срочно · ${overdueEvents.length}
`)); const list = el(`
`); overdueEvents.slice(0, 5).forEach(m => list.appendChild(renderTodayItem(m, "overdue"))); container.appendChild(list); } // Остальные на сегодня (кроме первого, который в hero) if (todayEvents.length > 1) { container.appendChild(el(`
📅 Ещё сегодня · ${todayEvents.length - 1}
`)); const list = el(`
`); todayEvents.slice(1).forEach(m => list.appendChild(renderTodayItem(m, "today"))); container.appendChild(list); } // Заявки без даты — напомнить созвониться с замерщиком if (noDateEvents.length > 0) { container.appendChild(el(`
📞 Без даты · ${noDateEvents.length}
`)); const list = el(`
`); noDateEvents.slice(0, 5).forEach(m => list.appendChild(renderTodayItem(m, "no_date"))); container.appendChild(list); } if (todayEvents.length === 0 && overdueEvents.length === 0 && noDateEvents.length === 0) { container.appendChild(el(`
Свободный день
Замеров на сегодня нет · можно поработать с клиентами или заказать новые замеры
`)); } } function renderTodayItem(m, kind) { const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, ""); const callHref = phoneClean ? `tel:${phoneClean}` : ""; let timeText = "—"; if (m.scheduled_at) { const d = new Date(m.scheduled_at); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); if (kind === "overdue") { timeText = `${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`; } else { timeText = `${hh}:${mi}`; } } else if (kind === "no_date") { timeText = "?"; } const row = el(`
${callHref ? `📞` : ""}
`); row.querySelector(".inbox-row-main").addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/clients/measurement/${m.id}`; }); return row; } function renderManagerProjects(container, measurements) { // Активные проекты = все замеры менеджера с любым статусом кроме completed/archived в обозримой перспективе. // Берём последние 5 по дате создания. const active = (measurements || []) .filter(m => m.status !== "archived") .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")) .slice(0, 5); container.innerHTML = ""; if (!active.length) return; container.appendChild(el(`
Активные проекты · ${active.length}
`)); const list = el(`
`); for (const m of active) { const stage = ({ requested: "Заявка на замер", scheduled: "Замер назначен", in_progress: "Замер в работе", completed: "Замер выполнен", })[m.status] || m.status; const statusKind = m.status === "completed" ? "active" : m.status === "requested" ? "waiting" : m.status === "scheduled" ? "active" : "waiting"; const dateLabel = m.scheduled_at ? new Date(m.scheduled_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : (m.created_at ? formatDateHuman(m.created_at).slice(0, 10) : "—"); const progress = ({ requested: 0.15, scheduled: 0.35, in_progress: 0.55, completed: 0.75, })[m.status] || 0.10; const card = el(`
${escHtml(m.client_name || "Без имени")}
${statusKind === "waiting" ? "Ожидает" : "В работе"}
${escHtml(m.address || "адрес не указан")}
${stage} ${dateLabel}
`); card.addEventListener("click", () => { haptic("impact"); location.hash = `#/clients/measurement/${m.id}`; }); list.appendChild(card); } container.appendChild(list); } /* ----------------- Менеджер: секция сборок в работе ----------------- */ function renderManagerAssemblies(container, assemblies) { container.innerHTML = ""; const ASSEMBLY_STATUS = { created: { icon: "🆕", label: "Создана", cls: "waiting" }, scheduled: { icon: "📅", label: "Запланирована", cls: "active" }, in_progress: { icon: "🔨", label: "В процессе", cls: "active" }, done: { icon: "✅", label: "Завершена", cls: "done" }, cancelled: { icon: "❌", label: "Отменена", cls: "cancel" }, }; // Показываем только активные (не завершённые и не отменённые) const active = (assemblies || []).filter(a => a.status !== "done" && a.status !== "cancelled"); if (!active.length) return; container.appendChild(el(`
🔨 Сборки в работе · ${active.length}
`)); for (const a of active) { const sl = ASSEMBLY_STATUS[a.status] || { icon: "🔧", label: a.status, cls: "waiting" }; const dateLabel = a.scheduled_at ? new Date(a.scheduled_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : "—"; const card = el(`
${escHtml(a.client_name || "Без имени")}
${sl.icon} ${sl.label}
${escHtml(a.address || "адрес не указан")}
${escHtml(a.scope_of_work || "—")} ${dateLabel}
`); card.addEventListener("click", () => { haptic("impact"); location.hash = `#/assembly/${a.id}`; }); container.appendChild(card); } } /* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */ function renderManagerShipments(container, groups, label = "📦 Отгрузки") { container.innerHTML = ""; if (!groups || !groups.length) return; // Показываем последние 3 партии (ближайшие по дате отгрузки с завода) const visible = groups.slice(-3); const totalItems = visible.reduce((s, g) => s + g.count, 0); container.appendChild(el(`
${escHtml(label)} · ${totalItems} поз.
`)); for (const group of visible) { const zakazBadge = group.count_zakazov ? `Заказов ${group.count_zakazov}` : ""; const dozBadge = group.count_dozakazov ? `Дозаказов ${group.count_dozakazov}` : ""; const groupEl = el(`
${escHtml(group.factory_date)} ${zakazBadge}${dozBadge}
`); const rowsEl = groupEl.querySelector(".ship-rows"); for (const item of group.items) { const typeClass = item.tovar.startsWith("Доз") ? "dozakaz" : "zakaz"; const typeMark = item.tovar.startsWith("Доз") ? "Дозаказ" : "Заказ"; const delivStr = item.delivery_date ? `📬 ${escHtml(item.delivery_date)}` : ""; const assembler = item.assembler ? `🔧 ${escHtml(item.assembler)}` : ""; const places = item.places ? `📦 ${escHtml(item.places)} м.` : ""; const meta = [delivStr, assembler, places].filter(Boolean).join(" · "); const furn = item.furn_spb ? `Фурн: ${escHtml(item.furn_spb)}` : ""; const pan = item.panels_spb ? `Пан: ${escHtml(item.panels_spb)}` : ""; const numStr = item.num ? `#${escHtml(item.num)} ` : ""; const contractStr = item.contract ? `Дог ${escHtml(item.contract)}` : ""; const noteStr = item.note ? `
${escHtml(item.note)}
` : ""; rowsEl.appendChild(el(`
${typeMark} ${numStr}${contractStr}
${meta ? `
${meta}
` : ""} ${furn || pan ? `
${furn}${pan}
` : ""} ${noteStr}
`)); } container.appendChild(groupEl); } } function renderBottomNav(active, opts = {}) { // Удаляем предыдущий, если есть const old = document.getElementById("bottom-nav"); if (old) old.remove(); const tabs = [ { key: "home", icon: "home", label: "Главная" }, { key: "projects", icon: "folder", label: "Проекты" }, { key: "fab", icon: "plus", label: "" }, { key: "chat", icon: "chat", label: "Чат", badge: opts.unreadChats || 0 }, { key: "profile", icon: "user", label: "Профиль" }, ]; const nav = el(``); tabs.forEach(t => { const isFab = t.key === "fab"; const isActive = t.key === active; const btn = el(` `); btn.addEventListener("click", () => { haptic("impact"); if (t.key !== active) Platform.showAlert(`«${t.label || "Новое"}» — скоро`); }); nav.appendChild(btn); }); document.body.appendChild(nav); } function renderClient(me) { const initial = me.user?.avatar_initial || getInitial(me.user?.full_name) || "?"; const greetName = me.user?.full_name || "Здравствуйте"; app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); app.appendChild(el(`
Клиент
${greetName}
${me.manager ? "Менеджер: " + me.manager.full_name + (me.manager.salon ? ", " + me.manager.salon : "") : "@wasrusgen1 · CRM"}
${initial}
`)); const sections = [ { label: "Мой заказ", items: [ { icon: "clipboard", color: "green", label: "Статус сборки", href: "#/c/orders", sub: "Этапы и таймлайн" }, { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, { icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" }, ], }, { label: "Подбор техники", items: [ { icon: "wrench", color: "green", label: "Подобрать встройку", href: "#/c/proposal" }, ], }, { label: "Помощь", items: [ { icon: "lightbulb", color: "gold", label: "Идеи и кейсы", soon: true }, { icon: "phone", color: "blue", label: "Связаться с менеджером", href: "#/c/contact" }, { icon: "pin", color: "green", label: "Записаться в салон", soon: true }, ], }, ]; sections.forEach(section => { app.appendChild(el(`
${section.label}
`)); app.appendChild(buildMenu(section.items)); }); app.appendChild(el(` `)); } function buildMenu(items) { const menu = el(``); items.forEach(item => { const cls = item.soon ? "menu-item disabled" : "menu-item"; const node = el(`
${ICONS[item.icon] || ""}
${item.label} ${item.soon ? 'скоро' : ""}
${item.sub ? `
${item.sub}
` : ""}
${item.soon ? "" : `
${ICONS.chevron}
`}
`); if (!item.soon) node.addEventListener("click", () => haptic("impact")); menu.appendChild(node); }); return menu; } /* ----------------- Role chooser — первый экран MiniApp ----------------- */ function renderRoleChooser() { app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); app.appendChild(el(`

Кто вы?

Выберите роль — кабинет откроется одним тапом.

Свой выбор можно изменить позже в профиле.

`)); app.querySelectorAll(".role-card").forEach(card => { card.addEventListener("click", () => { const role = card.dataset.role; haptic && haptic("impact"); // Меняем URL и перезапускаем init() — fetchMe пойдёт с правильной ролью const qp = new URLSearchParams(window.location.search); qp.set("role", role); history.replaceState(null, "", `?${qp.toString()}${location.hash || ""}`); // Показываем splash снова — на время загрузки const splashEl = document.createElement("div"); splashEl.id = "splash"; splashEl.className = "loader splash"; splashEl.innerHTML = `
Открываем кабинет
`; document.body.appendChild(splashEl); init(); }); }); } /* ----------------- Staff (замерщик / сборщик) ----------------- */ async function renderStaff(me) { app.innerHTML = ""; if (me.error === "no_staff_role") { app.appendChild(el(`
🔒

У вас нет
прав сотрудника

Чтобы получить роль замерщика или сборщика — отправьте куратору ваш Telegram ID.

Ваш ID ${me.user?.tg_id || "—"}
Имя ${me.user?.full_name || "—"}

В боте отправьте /whoami и перешлите ответ @wasrusgen.

`)); return; } const caps = me.capabilities || {}; const labels = []; if (caps.measurer) labels.push("замерщик"); if (caps.assembler) labels.push("сборщик"); if (caps.expeditor) labels.push("экспедитор"); if (caps.dispatcher) labels.push("диспетчер"); const subtitle = labels.length ? labels.join(" · ") : "сотрудник"; // Экспедитор — отдельный экран if (caps.dispatcher && !caps.measurer && !caps.assembler && !caps.expeditor) { location.hash = "#/dispatcher"; return; } if (caps.expeditor && !caps.measurer && !caps.assembler) { _renderExpeditorScreen(app, me); return; } app.appendChild(el(`
${me.user?.avatar_initial || "?"}
${subtitle}

${me.user?.full_name || "Сотрудник"}

`)); app.appendChild(renderPaletteSwitcher()); // Загружаем заявки и рендерим: week strip + сгруппированный инбокс const stripPlaceholder = el(`
`); const inboxSection = el(`
📥 Заявки
`); app.appendChild(stripPlaceholder); app.appendChild(inboxSection); if (caps.measurer) { try { const ctrl1 = new AbortController(); const t1 = setTimeout(() => ctrl1.abort(), 15000); const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, { method: "POST", signal: ctrl1.signal, body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }), }); clearTimeout(t1); const data = await res.json(); const list = document.getElementById("inboxList"); if (!list) return; if (data.error) { list.innerHTML = `
Ошибка: ${data.error}
`; } else { const measurements = data.measurements || []; // Week strip — заменяет placeholder document.getElementById("weekStrip").replaceWith(renderWeekStrip(measurements)); // Группированный инбокс renderGroupedInbox(list, measurements); } } catch (e) { const list = document.getElementById("inboxList"); if (list) list.innerHTML = `
Сеть: ${e.message}
`; } } else { document.getElementById("inboxList").innerHTML = `
У вас только роль «сборщик» — инбокс заявок на сборку появится позже.
`; } // Quick action — заполнить замер без заявки (вне очереди) if (caps.measurer) { const quick = el(`
`); quick.querySelector("#newMeasure").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/measure"; }); app.appendChild(quick); } // Сборки — отдельный блок, доступен мастеру (measurer ∨ assembler) if (caps.measurer || caps.assembler) { const assemblySection = el(`
🔨 Сборки
`); app.appendChild(assemblySection); renderStaffAssemblies(assemblySection.querySelector("#assemblyList")); } // Список клиентов — сборщик и/или замерщик if (caps.assembler || caps.measurer) { const clientsBtn = el(`
`); clientsBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/master/clients"; }); app.appendChild(clientsBtn); } // Статистика замерщика if (caps.measurer) { const measStatsBtn = el(`
`); measStatsBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/master/measurer-stats"; }); app.appendChild(measStatsBtn); } // Шпаргалки + заработки сборщика if (caps.assembler) { const earningsBtn = el(`
`); earningsBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/master/dashboard"; }); app.appendChild(earningsBtn); const toolsBtn = el(`
`); toolsBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/master/tools"; }); app.appendChild(toolsBtn); } // Мои оценки — для всех сотрудников const myRatingsBtn = el(`
`); myRatingsBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/feedback/my"; }); app.appendChild(myRatingsBtn); } /* ── Экран экспедитора ─────────────────────────────────────────── */ function _renderExpeditorScreen(container, me) { const u = me.user || {}; container.appendChild(el(`
${u.avatar_initial || "Э"}
${escHtml(u.full_name || "Экспедитор")}
экспедитор
`)); container.appendChild(el(`
Выберите сборку для оформления приёмки товара
`)); // Список активных сборок const asmWrap = el(`
`); container.appendChild(asmWrap); _loadExpeditorAssemblies(asmWrap); } async function _loadExpeditorAssemblies(container) { container.innerHTML = `
`; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 15000); const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }), signal: ctrl, }); clearTimeout(t); const data = await res.json(); if (data.error) { container.innerHTML = `
${escHtml(data.error)}
`; return; } const assemblies = (data.assemblies || []).filter(a => !["done","cancelled"].includes(a.status)); if (!assemblies.length) { container.innerHTML = `
Нет активных сборок
`; return; } container.innerHTML = ""; assemblies.forEach(a => { const card = el(`
${escHtml(a.client_name || "Клиент")}
${escHtml(a.address || "")}
📦 Оформить приёмку
`); card.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/expeditor/act/${a.id}`; }); container.appendChild(card); }); } catch (e) { container.innerHTML = `
${escHtml(e.message)}
`; } } async function renderStaffAssemblies(container) { try { const ctrl2 = new AbortController(); const t2 = setTimeout(() => ctrl2.abort(), 15000); const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { method: "POST", signal: ctrl2.signal, body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }), }); clearTimeout(t2); const data = await res.json(); if (data.error) { container.innerHTML = `
Ошибка: ${escHtml(data.error)}
`; return; } const items = (data.assemblies || []).filter(a => a.status !== "completed" && a.status !== "cancelled"); if (!items.length) { container.innerHTML = `
Сборок нет
`; return; } container.innerHTML = ""; for (const a of items) { const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "— дата не назначена"; const statusLabel = { created: "📝 создана", scheduled: "📅 назначена", in_progress: "🔧 в работе", }[a.status] || a.status; const card = el(`
${statusLabel} ${escHtml(dateStr)}
${escHtml(a.client_name || "Без имени")}
${escHtml(a.address || "адрес не указан")}
${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0, 100))}${a.scope_of_work.length > 100 ? "…" : ""}
` : ""}
`); card.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/assembly/${a.id}`; }); container.appendChild(card); } } catch (e) { container.innerHTML = `
Сеть: ${escHtml(e.message)}
`; } } /* ----------------- Группировка инбокса замерщика по дням ----------------- */ function _startOfDay(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; } function _daysBetween(a, b) { return Math.round((_startOfDay(b) - _startOfDay(a)) / 86400000); } function _groupForMeasurement(m, today, weekEnd) { if (!m.scheduled_at) { // Без даты — отделяем requested от scheduled (по идее scheduled без даты быть не должно) return { key: "no_date", title: "📞 Без даты — нужно согласовать", order: 5 }; } const d = new Date(m.scheduled_at); const diff = _daysBetween(today, d); if (diff < 0) return { key: "overdue", title: "⚠️ Просрочено", order: 0 }; if (diff === 0) return { key: "today", title: "🔥 Сегодня", order: 1 }; if (diff === 1) return { key: "tomorrow", title: "📅 Завтра", order: 2 }; if (d <= weekEnd) return { key: "this_week", title: "🗓️ На неделе", order: 3 }; return { key: "later", title: "📆 Позже", order: 4 }; } function renderGroupedInbox(container, measurements) { container.innerHTML = ""; if (!measurements.length) { container.innerHTML = `
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
`; return; } const today = _startOfDay(new Date()); // Конец этой недели: воскресенье вечером const weekEnd = new Date(today); const dayIdx = (today.getDay() + 6) % 7; // 0 = Пн, 6 = Вс weekEnd.setDate(today.getDate() + (6 - dayIdx)); weekEnd.setHours(23, 59, 59, 999); // Группируем const groups = new Map(); for (const m of measurements) { const g = _groupForMeasurement(m, today, weekEnd); if (!groups.has(g.key)) groups.set(g.key, { ...g, items: [] }); groups.get(g.key).items.push(m); } // Сортируем группы и внутри — по дате const sortedGroups = [...groups.values()].sort((a, b) => a.order - b.order); for (const g of sortedGroups) { g.items.sort((a, b) => (a.scheduled_at || "").localeCompare(b.scheduled_at || "")); const groupEl = el(`
${g.title}${g.items.length}
`); const list = groupEl.querySelector(".inbox-group-list"); g.items.forEach(m => list.appendChild(renderInboxItem(m, g.key))); container.appendChild(groupEl); } } /* ----------------- Week strip — загрузка по дням ----------------- */ function renderWeekStrip(measurements) { const today = _startOfDay(new Date()); const dayIdx = (today.getDay() + 6) % 7; // Пн = 0 const monday = new Date(today); monday.setDate(today.getDate() - dayIdx); const days = []; for (let i = 0; i < 7; i++) { const d = new Date(monday); d.setDate(monday.getDate() + i); days.push(d); } // Считаем сколько замеров на каждый день const countByDay = days.map(d => { const start = _startOfDay(d).getTime(); const end = start + 86400000; return measurements.filter(m => { if (!m.scheduled_at) return false; const t = new Date(m.scheduled_at).getTime(); return t >= start && t < end; }).length; }); const maxCount = Math.max(1, ...countByDay); const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const section = el(`
${monday.getDate()}–${days[6].getDate()} ${monday.toLocaleString("ru-RU", { month: "long" })}
${days.map((d, i) => { const cnt = countByDay[i]; const heightPct = cnt ? Math.round((cnt / maxCount) * 100) : 0; const isToday = _startOfDay(d).getTime() === today.getTime(); const isPast = _startOfDay(d).getTime() < today.getTime(); const loadClass = cnt >= 5 ? "load-hot" : cnt >= 3 ? "load-mid" : cnt > 0 ? "load-low" : "load-zero"; return `
${dayNames[i]}
${d.getDate()}
${cnt || "—"}
`; }).join("")}
`); return section; } /* ----------------- Карточка заявки в инбоксе ----------------- */ function renderInboxItem(m, groupKey) { // Когда: точное время если назначено + день недели для не-today let timeLine; if (m.scheduled_at) { const d = new Date(m.scheduled_at); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); if (groupKey === "today" || groupKey === "tomorrow") { timeLine = `${hh}:${mi}`; } else if (groupKey === "overdue") { timeLine = `${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`; } else { const dayNames = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"]; timeLine = `${dayNames[d.getDay()]} ${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`; } } else { timeLine = formatPreferredHuman(m); } const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, ""); const callHref = phoneClean ? `tel:${phoneClean}` : ""; const item = el(`
${callHref ? `📞` : ""}
`); item.querySelector(".inbox-row-main").addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/inbox/${m.id}`; }); return item; } function formatPreferredHuman(m) { // Теперь приоритет — текст из preferred_note (свободная форма). // Старые записи с preferred_type/date/time_of_day выводятся как fallback. if (m.preferred_note) return m.preferred_note; const todMap = { morning: "утром", day: "днём", evening: "вечером" }; const t = m.preferred_type || "tbd"; const parts = []; if (t === "specific") { if (m.preferred_date) { try { const d = new Date(m.preferred_date); parts.push(`${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")}`); } catch (e) { parts.push(m.preferred_date); } } if (m.preferred_time_of_day && todMap[m.preferred_time_of_day]) { parts.push(todMap[m.preferred_time_of_day]); } if (!parts.length) parts.push("конкретная дата"); } else if (t === "this_week") { parts.push("эта неделя"); } else if (t === "next_week") { parts.push("следующая неделя"); } else { parts.push("согласовать с клиентом"); } return parts.join(" "); } function formatDateHuman(iso) { if (!iso) return "—"; try { const d = new Date(iso); const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = d.getFullYear(); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return `${dd}.${mm}.${yy} ${hh}:${mi}`; } catch (e) { return iso; } } function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } /* ----------------- Карточка заявки для замерщика ----------------- */ async function renderInboxDetail(measurementId) { app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); // header const header = el(`
Заявка на замер
`); header.querySelector(".podbor-back").addEventListener("click", () => { // Возврат в главное меню без перезагрузки (иначе сплэш мигает) location.hash = ""; routeByHash(); }); app.appendChild(header); const loading = el(`
`); app.appendChild(loading); let m; try { const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { method: "POST", body: JSON.stringify({ initData: Platform.initData, measurement_id: measurementId }), }); m = await res.json(); } catch (e) { loading.remove(); app.appendChild(el(`
Сеть: ${e.message}
`)); return; } loading.remove(); if (m.error) { app.appendChild(el(`
${m.error}
`)); return; } // Шапка app.appendChild(el(`
Заявка #${(m.id || "").slice(0, 8)}

${escHtml(m.client_name || "Без имени")}

📞 ${escHtml(m.client_phone || "—")} 📍 ${escHtml(m.address || "адрес не указан")}
`)); // Примечание от менеджера (рекомендации по дате, особенности доступа) if (m.preferred_note) { app.appendChild(el(`
📝 Примечание менеджера
${escHtml(m.preferred_note).replace(/\n/g, "
")}
`)); } // Блок логистики (подъезд, GPS, парковка) — заполняется на месте app.appendChild(renderLogisticsBlock(m)); // Блок даты замера — две версии в зависимости от статуса const isScheduled = m.status === "scheduled" && m.scheduled_at; if (isScheduled) { // Дата назначена — показываем её крупно + кнопка «Изменить» const dateSection = el(`
📅 Замер назначен
${escHtml(formatDateHuman(m.scheduled_at))}
${m.gcal_event_url ? `
📅 Открыть в Google Calendar
` : ""}
`); app.appendChild(dateSection); dateSection.querySelector("#changeDate").addEventListener("click", () => { dateSection.querySelector("#changeDateForm").style.display = ""; dateSection.querySelector("#changeDate").style.display = "none"; }); dateSection.querySelector("#cancelChange").addEventListener("click", () => { dateSection.querySelector("#changeDateForm").style.display = "none"; dateSection.querySelector("#changeDate").style.display = ""; }); dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection)); // ОСНОВНАЯ кнопка — начать замер (открывает мастер с чек-листом) const startSection = el(`
Чек-лист, фото и заметки откроются после нажатия.
`); app.appendChild(startSection); startSection.querySelector("#startMeasure").addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/measure?id=${measurementId}`; }); } else { // Дата не назначена — основной шаг: согласовать и назначить const dateSection = el(`
📞 Согласовать дату с клиентом
Позвоните клиенту, договоритесь о точной дате и времени, затем зафиксируйте здесь.
`); app.appendChild(dateSection); dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection)); } // === Кнопка «Выставить счёт» (замерщик) === if (m.viewer_is_measurer) { const invoiceWrap = el('
'); const hasFee = parseFloat(m.measurement_fee||0) > 0; invoiceWrap.appendChild(el( '' )); invoiceWrap.querySelector('#invoiceBtn').addEventListener('click', () => { haptic && haptic('impact'); location.hash = '#/master/invoice/' + measurementId; }); app.appendChild(invoiceWrap); } // === Обратная связь — замерщик оценивает менеджера === if (m.status === "completed" && m.viewer_is_measurer && !m.measurer_feedback_at && typeof FeedbackModule !== "undefined") { const fbWrap = el(`
`); app.appendChild(fbWrap); FeedbackModule.mountMeasurerFeedback(fbWrap, { managerName: m.manager_name || "", managerTgId: m.manager_tg_id || "", measurementId: m.id, onSubmit: () => renderInboxDetail(measurementId), }); } // === Обратная связь — менеджер оценивает замерщика === if (m.status === "completed" && m.viewer_is_manager && !m.manager_feedback_at && typeof FeedbackModule !== "undefined") { const fbWrap2 = el(`
`); app.appendChild(fbWrap2); FeedbackModule.mountManagerFeedback(fbWrap2, { measurerName: m.measurer_name || "", measurerTgId: m.measurer_tg_id || "", measurementId: m.id, onSubmit: () => renderInboxDetail(measurementId), }); } } async function saveScheduleDate(measurementId, section) { const input = section.querySelector("#schedInput"); const errorEl = section.querySelector("#schedError"); if (errorEl) errorEl.textContent = ""; const val = input.value; if (!val) { if (errorEl) errorEl.textContent = "Укажите дату и время"; return; } const iso = new Date(val).toISOString(); try { const res = await fetch(`${BACKEND_URL}/api/measurement_schedule`, { method: "POST", body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe, measurement_id: measurementId, scheduled_at: iso, }), }); const data = await res.json(); if (data.error) { if (errorEl) errorEl.textContent = "Ошибка: " + data.error; return; } haptic && haptic("success"); Platform.showAlert("Дата сохранена — менеджер уведомлён."); renderInboxDetail(measurementId); // перерисовать с новым статусом } catch (e) { if (errorEl) errorEl.textContent = "Сеть: " + e.message; } } function renderLogisticsBlock(m) { const hasData = !!(m.entrance || m.floor || m.gps_lat || m.parking_type || m.parking_note || m.delivery_notes); const parkingLabels = { free: "🅿️ Бесплатная", paid: "💰 Платная", street: "🛣️ На улице", none: "🚫 Нет парковки", }; const section = el(`
📍 Логистика ${hasData ? '' : ''}
`); // Сводка (когда не в режиме редактирования) function updateSummary(curM) { const sum = section.querySelector("#logSummary"); const lines = []; if (curM.entrance) lines.push(`Подъезд ${escHtml(curM.entrance)}`); if (curM.floor) lines.push(`этаж ${escHtml(curM.floor)}`); if (curM.gps_lat && curM.gps_lng) { const ymUrl = `https://yandex.ru/maps/?pt=${curM.gps_lng},${curM.gps_lat},pm2rdm&z=17&ll=${curM.gps_lng},${curM.gps_lat}`; lines.push(`📍 ${curM.gps_lat}, ${curM.gps_lng}`); } if (curM.parking_type && parkingLabels[curM.parking_type]) { let p = parkingLabels[curM.parking_type]; if (curM.parking_note) p += ` · ${escHtml(curM.parking_note)}`; lines.push(p); } if (curM.delivery_notes) { lines.push(`${escHtml(curM.delivery_notes)}`); } sum.innerHTML = lines.length ? lines.join(" · ") : `Информация для подъезда не заполнена — заполни при выезде.`; } updateSummary(m); const editor = section.querySelector("#logEditor"); const summary = section.querySelector("#logSummary"); const toggleBtn = section.querySelector("#logToggle"); function setEdit(on) { editor.style.display = on ? "" : "none"; summary.style.display = on ? "none" : ""; toggleBtn.style.display = on ? "none" : ""; } toggleBtn.addEventListener("click", () => setEdit(true)); section.querySelector("#logCancel").addEventListener("click", () => setEdit(false)); // GPS «Сейчас» section.querySelector("#getGps").addEventListener("click", () => { const hint = section.querySelector("#gpsHint"); hint.textContent = "Запрашиваем координаты..."; if (!navigator.geolocation) { hint.textContent = "Геолокация недоступна. Введите вручную."; return; } navigator.geolocation.getCurrentPosition( (pos) => { const lat = pos.coords.latitude.toFixed(6); const lng = pos.coords.longitude.toFixed(6); section.querySelector("#logGps").value = `${lat}, ${lng}`; hint.textContent = `Получено · точность ${Math.round(pos.coords.accuracy)} м`; haptic && haptic("success"); }, (err) => { hint.textContent = `Не удалось: ${err.message || "отказано в доступе"}`; }, { enableHighAccuracy: true, timeout: 12000, maximumAge: 60000 } ); }); // GPS «По адресу» — геокодирование через backend section.querySelector("#getGpsAddr").addEventListener("click", async () => { const hint = section.querySelector("#gpsHint"); const addr = (m.address || "").trim(); if (!addr) { hint.textContent = "В заявке нет адреса — нужен текст адреса для геокодера."; return; } hint.textContent = "Ищем по адресу..."; try { const res = await fetch(`${BACKEND_URL}/api/geocode`, { method: "POST", body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe, address: addr, }), }); const data = await res.json(); if (!data.ok || !data.result) { hint.textContent = "Адрес не найден геокодером — введите GPS вручную."; return; } const r = data.result; section.querySelector("#logGps").value = `${r.lat.toFixed(6)}, ${r.lng.toFixed(6)}`; const srcLabel = r.source === "yandex" ? "Я.Геокодер" : "OSM"; hint.textContent = `Найдено: ${r.formatted || addr} · источник ${srcLabel}`; haptic && haptic("success"); } catch (e) { hint.textContent = "Сеть: " + e.message; } }); // Сохранение section.querySelector("#logSave").addEventListener("click", async () => { const btn = section.querySelector("#logSave"); btn.disabled = true; btn.textContent = "Сохраняем..."; const gpsStr = (section.querySelector("#logGps").value || "").trim(); let gps_lat = "", gps_lng = ""; if (gpsStr) { const parts = gpsStr.split(/[,;\s]+/).filter(Boolean); if (parts.length >= 2) { gps_lat = parts[0]; gps_lng = parts[1]; } } const parkType = (section.querySelector('input[name="parkType"]:checked') || {}).value || ""; const payload = { initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe, measurement_id: m.id, entrance: section.querySelector("#logEntrance").value, floor: section.querySelector("#logFloor").value, gps_lat, gps_lng, parking_type: parkType, parking_note: section.querySelector("#logParkNote").value, delivery_notes: section.querySelector("#logDelivery").value, }; try { const res = await fetch(`${BACKEND_URL}/api/measurement_logistics`, { method: "POST", body: JSON.stringify(payload), }); const data = await res.json(); if (data.error) { btn.disabled = false; btn.textContent = "Сохранить"; alert("Ошибка: " + data.error); return; } // Обновляем локальные данные и сводку Object.assign(m, data.logistics || {}); updateSummary(m); setEdit(false); // Обновляем точку-индикатор «есть данные» const hasNow = !!(m.entrance || m.floor || m.gps_lat || m.parking_type || m.parking_note || m.delivery_notes); const head = section.querySelector("#logHead span"); head.innerHTML = `📍 Логистика ${hasNow ? '' : ''}`; toggleBtn.textContent = hasNow ? "Изменить" : "Заполнить"; btn.disabled = false; btn.textContent = "Сохранить"; haptic && haptic("success"); } catch (e) { btn.disabled = false; btn.textContent = "Сохранить"; alert("Сеть: " + e.message); } }); return section; } function toDatetimeLocalValue(iso) { // ISO → YYYY-MM-DDTHH:MM для try { const d = new Date(iso); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } catch (e) { return ""; } } function renderError() { app.innerHTML = ""; app.appendChild(el(`

Не удалось загрузить кабинет

Проверьте подключение и попробуйте позже.
`)); } /* ----------------- Init ----------------- */ // Засекаем когда стартовали — чтобы splash висел минимум ~700мс const _splashStart = Date.now(); function hideSplash() { const splash = document.getElementById("splash"); if (!splash) return; const elapsed = Date.now() - _splashStart; const minShow = 840; // минимум показа, мс — было 1200, сокращено на 30% const wait = Math.max(0, minShow - elapsed); setTimeout(() => { splash.classList.add("hide"); setTimeout(() => splash.remove(), 450); }, wait); } let _hashListenerAdded = false; async function init() { setupTelegram(); if (!_hashListenerAdded) { window.addEventListener("hashchange", routeByHash); _hashListenerAdded = true; } const qp = new URLSearchParams(window.location.search); // Telegram ставит #tgWebAppData=... в hash при открытии — это НЕ наш роут. // Считаем «есть навигационный hash» только если он начинается с #/ const hasAppRoute = location.hash.startsWith("#/"); const goScreen = qp.get("go"); if (goScreen && !hasAppRoute) { const map = { podbor: "#/podbor", clients: "#/clients", measure: "#/measure", request: "#/request", }; if (map[goScreen]) { history.replaceState(null, "", location.pathname + location.search + map[goScreen]); } } // Если нет ?role= в URL — показываем выбор роли (универсально для всех клиентов) const explicitRole = qp.get("role"); if (!explicitRole && !hasAppRoute) { renderRoleChooser(); hideSplash(); return; } try { const me = await fetchMe(); window.__zovMe = me; // кешируем профиль для подэкранов // Единая точка роутинга — routeByHash покрывает все маршруты routeByHash(); hideSplash(); } catch (e) { console.error(e); renderError(); hideSplash(); } } function routeByHash() { if (location.hash.startsWith("#/podbor")) { Podbor.mount(app); } else if (location.hash.startsWith("#/clients")) { Clients.mount(app); } else if (location.hash.startsWith("#/measure")) { Measurements.mount(app); } else if (location.hash.startsWith("#/request")) { MeasurementRequest.mount(app); } else if (location.hash.startsWith("#/inbox/")) { renderInboxDetail(location.hash.replace("#/inbox/", "")); } else if (location.hash === "#/inbox") { if (typeof InboxScreen !== "undefined") InboxScreen.mount(app); else init(); } else if (location.hash.startsWith("#/assembly")) { Assembly.mount(app); } else if (location.hash === "#/admin/staff") { if (typeof StaffRoster !== "undefined") StaffRoster.mount(app); else init(); } else if (location.hash === "#/admin/finance") { if (typeof FinanceSummary !== "undefined") FinanceSummary.mount(app); else init(); } else if (location.hash === "#/admin/assembler-analytics") { if (typeof AssemblerAnalytics !== "undefined") AssemblerAnalytics.mount(app); else init(); } else if (location.hash.startsWith("#/admin/rates")) { if (typeof AdminRates !== "undefined") AdminRates.mount(app); else init(); } else if (location.hash === "#/master/clients") { if (typeof StaffClients !== "undefined") StaffClients.mount(app); else init(); } else if (location.hash === "#/master/dashboard") { if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app); else init(); } else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/contract")) { const asmId = location.hash.split("/")[2]; if (typeof Contracts !== "undefined") Contracts.mount(app, asmId); else init(); } else if (location.hash === "#/expeditor") { if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mount(app); else init(); } else if (location.hash === "#/dispatcher") { if (typeof DispatcherDashboard !== "undefined") DispatcherDashboard.mount(app); else init(); } else if (location.hash.startsWith("#/expeditor/act/")) { const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0]; if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId); else init(); } else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/extra_acts")) { const asmId = location.hash.split("/")[2]; if (typeof ExtraActs !== "undefined") ExtraActs.mount(app, asmId); else init(); } else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/act4")) { const asmId = location.hash.split("/")[2]; if (typeof Act4Screen !== "undefined") Act4Screen.mount(app, asmId); else init(); } else if (location.hash === "#/master/measurer-stats") { if (typeof MeasurerDashboard !== "undefined") MeasurerDashboard.mount(app); else init(); } else if (location.hash.startsWith("#/master/invoice/")) { const mId = location.hash.replace("#/master/invoice/", "").split("?")[0]; if (typeof InvoiceScreen !== "undefined") InvoiceScreen.mount(app, mId); else init(); } else if (location.hash.startsWith("#/master/tools")) { if (typeof MasterTools !== "undefined") { const h = location.hash; if (h === "#/master/tools/rails") MasterTools.mountRails(app); else if (h === "#/master/tools/shelves") MasterTools.mountShelves(app); else if (h === "#/master/tools/price") MasterTools.mountPrice(app); else MasterTools.mountMenu(app); } else init(); } else if (location.hash.startsWith("#/master")) { const me = window.__zovMe; if (me) renderStaff(me); else init(); } else if (location.hash.startsWith("#/me")) { if (typeof MeScreen !== "undefined") MeScreen.mount(app); else init(); } else if (location.hash === "#/c/cabinet") { if (typeof CabinetScreen !== "undefined") CabinetScreen.mount(app); else init(); } else if (location.hash.startsWith("#/c/proposal")) { app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav2 = document.getElementById("bottom-nav"); if (oldNav2) oldNav2.remove(); if (typeof Proposals !== "undefined") { Proposals.mountClient(app); } else { app.innerHTML = `
Модуль подбора не загружен
`; } } else if (location.hash.startsWith("#/c/contract")) { app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav3 = document.getElementById("bottom-nav"); if (oldNav3) oldNav3.remove(); if (typeof Proposals !== "undefined") { Proposals.mountContractReview(app); } else { app.innerHTML = `
Модуль не загружен
`; } } else if (location.hash === "#/c/orders") { if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app); else init(); } else if (location.hash.startsWith("#/c/assembly/") && location.hash.endsWith("/timeline")) { const parts = location.hash.split("/"); // #/c/assembly/ID/timeline → parts = ["#", "c", "assembly", "ID", "timeline"] const asmIdRaw = parts[parts.length - 2] || ""; const asmId = decodeURIComponent(asmIdRaw); if (typeof ClientTimeline !== "undefined") ClientTimeline.mount(app, asmId); else init(); } else if (location.hash.startsWith("#/c/assembly/")) { const assemblyId = decodeURIComponent(location.hash.replace("#/c/assembly/", "")); if (typeof AssemblyDetailScreen !== "undefined") AssemblyDetailScreen.mount(app, assemblyId); else init(); } else if (location.hash === "#/c/selfmeasure") { if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app); else init(); } else if (location.hash === "#/feedback/my") { if (typeof FeedbackModule !== "undefined") FeedbackModule.mountMyScreen(app); else init(); } else { // Главный экран по роли const me = window.__zovMe; if (!me) { init(); return; } if (me.role === "staff") renderStaff(me); else if (me.role === "manager") renderManager(me); else renderClient(me); } } init();