// ЗОВ MiniApp — главный скрипт. // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. const tg = window.Telegram?.WebApp; // Cloudflare Quick Tunnel → VPS FastAPI backend (GigaChat). // Временный URL — пока wasrusgen1.pro в verification-hold; затем переключим на https://api.wasrusgen1.pro const BACKEND_URL = "https://prepared-alfred-story-dale.trycloudflare.com"; const app = document.getElementById("app"); /* ----------------- Telegram WebApp setup ----------------- */ function setupTelegram() { const scheme = tg?.colorScheme || (window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"); document.documentElement.setAttribute("data-theme", scheme); // Зафиксирован вариант A — Editorial Calm document.documentElement.setAttribute("data-variant", "a"); if (!tg) return; try { tg.ready(); tg.expand(); if (tg.onEvent) tg.onEvent("themeChanged", () => { document.documentElement.setAttribute("data-theme", tg.colorScheme || "light"); }); if (tg.enableClosingConfirmation) tg.enableClosingConfirmation(); } catch (e) { console.warn(e); } } function haptic(type = "selection") { try { if (!tg?.HapticFeedback) return; if (type === "impact") tg.HapticFeedback.impactOccurred("light"); else if (type === "success") tg.HapticFeedback.notificationOccurred("success"); else tg.HapticFeedback.selectionChanged(); } catch (e) {} } /* ----------------- 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: tg?.initData || "", // Fallback для Telegram Desktop side-panel где initData может приходить пустым. // Backend проверит подпись initData первым; если её нет — упадёт сюда. UNSAFE! initDataUnsafe: tg?.initDataUnsafe || null, startParam: tg?.initDataUnsafe?.start_param || null, 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]; } function renderManagerHome(me) { // === MOCK DATA (Этап 1 — визуал, без реального backend) === const firstName = (me.user?.full_name || "").split(/\s+/)[0] || "Артём"; const todayTask = { time: "15:30", tag: "ЗАМЕР", client: "А. Пестова", address: "ЖК Сады Пекина, корп. 3", phone: "+7 999 000-00-00", }; const projects = [ { name: "Семья Иваниковых", address: "ул. Орджоникидзе, 14 — 47", stage: "Согласование", date: "14 мая", progress: 0.40, statusLabel: "Ожидает клиента", statusKind: "waiting" }, { name: "Кабанова И. С.", address: "Никольская набережная, 20", stage: "Производство", date: "21 мая", progress: 0.60, statusLabel: "В работе", statusKind: "active" }, { name: "Карелин А.", address: "посёлок Сосновый, дом 4", stage: "Замер", date: "сегодня", progress: 0.10, statusLabel: "Срочно", statusKind: "urgent" }, { name: "Петросян Г.", address: "ул. Лесная, 18 — 12", stage: "Доставка", date: "16 мая", progress: 0.85, statusLabel: "В работе", statusKind: "active" }, { name: "Тимирясов И.", address: "пос. Барвиха, дом 8", stage: "Монтаж", date: "11 мая", progress: 0.95, statusLabel: "Завершается", statusKind: "active" }, ]; const unreadChats = 2; const tasksTodayCount = todayTask ? 1 : 0; const taskWord = pluralRu(tasksTodayCount, ["замер", "замера", "замеров"]); const phraseTail = tasksTodayCount === 0 ? "ничего на сегодня" : `${tasksTodayCount === 1 ? "один" : tasksTodayCount} ${taskWord} сегодня`; // === RENDER === app.innerHTML = ""; document.body.classList.add("has-bottom-nav"); // Greeting app.appendChild(el(`
${timeOfDay()}
${firstName},
${phraseTail}
`)); // Hero task if (todayTask) { app.appendChild(el(`
На сегодня${todayTask.time} ${todayTask.tag}
${todayTask.client}
${todayTask.address}
${ICONS.phone}
`)); } // Quick actions const quickActions = [ { icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" }, ]; 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 tg?.showAlert?.(`«${qa.title}» — скоро`); }); grid.appendChild(card); }); app.appendChild(grid); // Active projects app.appendChild(el(`
Активные проекты · ${projects.length} Все
`)); const list = el(`
`); projects.forEach(p => { const card = el(`
${p.name}
${p.statusLabel}
${p.address}
${p.stage} ${p.date}
`); card.addEventListener("click", () => { haptic("impact"); tg?.showAlert?.(`Проект «${p.name}» — скоро`); }); list.appendChild(card); }); app.appendChild(list); // Bottom nav (fixed, outside #app) renderBottomNav("home", { unreadChats }); } 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) tg?.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 : "") : "ЗОВ — кухонная мебель"}
${initial}
`)); const sections = [ { label: "Подобрать кухню", items: [ { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, { icon: "wrench", color: "green", label: "Подобрать технику", soon: true }, { icon: "wallet", color: "gold", label: "Калькулятор бюджета", soon: true }, ], }, { 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("сборщик"); const subtitle = labels.length ? labels.join(" · ") : "сотрудник"; app.appendChild(el(`
${me.user?.avatar_initial || "?"}
${subtitle}

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

`)); // Реальный инбокс — загружаем из /api/measurement_inbox const inboxSection = el(`
📥 Входящие заявки на замер
`); app.appendChild(inboxSection); if (caps.measurer) { try { const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "" }), }); const data = await res.json(); const list = document.getElementById("inboxList"); if (!list) return; if (data.error) { list.innerHTML = `
Ошибка: ${data.error}
`; } else if (!data.measurements || !data.measurements.length) { list.innerHTML = `
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
`; } else { list.innerHTML = ""; data.measurements.forEach(m => list.appendChild(renderInboxItem(m))); } } 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); } } function renderInboxItem(m) { const statusLabel = ({ requested: "🟡 ждёт даты", scheduled: "📅 назначен", in_progress: "🔵 в работе", })[m.status] || m.status; // Когда: точная дата если назначена, иначе приблизительная let whenText; if (m.scheduled_at) { whenText = "📅 " + formatDateHuman(m.scheduled_at); } else { whenText = "🕐 " + formatPreferredHuman(m); } const item = el(` `); item.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/inbox/${m.id}`; }); return item; } function formatPreferredHuman(m) { 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("согласовать с клиентом"); } let s = parts.join(" "); if (m.preferred_note) s += " · " + m.preferred_note; return s; } 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 = ""; if (!location.hash) location.reload(); }); 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: tg?.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.scheduled_at && (m.preferred_type || m.preferred_note)) { const prefText = formatPreferredHuman(m); app.appendChild(el(`
⏰ Когда удобно клиенту (от менеджера)
${escHtml(prefText)}
Позвоните клиенту и согласуйте точную дату — она появится ниже.
`)); } // Заметки от менеджера if (m.notes) { app.appendChild(el(`
Заметки от менеджера
${escHtml(m.notes).replace(/\n/g, "
")}
`)); } // Блок логистики — заполняется замерщиком/сборщиком на месте app.appendChild(renderLogisticsBlock(m)); // Блок «назначить дату» (если ещё requested) или «изменить дату» (если scheduled) const isScheduled = m.status === "scheduled"; const schedSection = el(`
${isScheduled ? "Дата замера" : "Назначить дату"}
`); app.appendChild(schedSection); schedSection.querySelector("#saveSched").addEventListener("click", async () => { const input = schedSection.querySelector("#schedInput"); const errorEl = schedSection.querySelector("#schedError"); errorEl.textContent = ""; const val = input.value; if (!val) { 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: tg?.initData || "", measurement_id: measurementId, scheduled_at: iso, }), }); const data = await res.json(); if (data.error) { errorEl.textContent = "Ошибка: " + data.error; return; } haptic && haptic("success"); tg?.showAlert?.("Дата назначена — менеджер уведомлён."); renderInboxDetail(measurementId); // перерисовать } catch (e) { errorEl.textContent = "Сеть: " + e.message; } }); // Кнопка «Сделать замер» (только если назначено или прямо сейчас) const measureBtn = el(`
`); measureBtn.querySelector("#goMeasure").addEventListener("click", () => { haptic && haptic("impact"); // Передаём measurement_id чтобы wizard работал в update-mode location.hash = `#/measure?id=${measurementId}`; }); app.appendChild(measureBtn); } 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 url = `https://maps.google.com/?q=${curM.gps_lat},${curM.gps_lng}`; 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 } ); }); // Сохранение 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: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, 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 = 1200; // минимум показа, мс — 1.2 сек хватает чтобы рассмотреть лого и не блокировать UI const wait = Math.max(0, minShow - elapsed); setTimeout(() => { splash.classList.add("hide"); setTimeout(() => splash.remove(), 450); }, wait); } async function init() { setupTelegram(); window.addEventListener("hashchange", routeByHash); 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; // кешируем профиль для подэкранов if (location.hash.startsWith("#/podbor")) { Podbor.mount(app); hideSplash(); return; } if (location.hash.startsWith("#/clients")) { Clients.mount(app); hideSplash(); return; } if (location.hash.startsWith("#/measure")) { Measurements.mount(app); hideSplash(); return; } if (location.hash.startsWith("#/request")) { MeasurementRequest.mount(app); hideSplash(); return; } if (location.hash.startsWith("#/inbox/")) { const id = location.hash.replace("#/inbox/", ""); renderInboxDetail(id); hideSplash(); return; } if (me.role === "staff") { renderStaff(me); } else if (me.role === "manager") { renderManager(me); } else { renderClient(me); } 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 { // Главный экран по роли 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();