// ЗОВ 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]; } async function renderManagerHome(me) { const firstName = (me.user?.full_name || "").split(/\s+/)[0] || "Менеджер"; app.innerHTML = ""; document.body.classList.add("has-bottom-nav"); // 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: "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); // Активные проекты — будет наполняться позже из реальных данных const projectsContainer = el(`
`); app.appendChild(projectsContainer); renderBottomNav("home", { unreadChats: 0 }); // Параллельно грузим реальные данные try { const res = await fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, }), }); const data = await res.json(); const measurements = (data.measurements || []); renderManagerToday(todayContainer, measurements, firstName, greetingEl); renderManagerProjects(projectsContainer, measurements); } catch (e) { todayContainer.innerHTML = `
Не удалось загрузить данные: ${escHtml(e.message)}
`; } } 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 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 : "") : "@wasrusgen1 · сборщик"}
${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 || "Сотрудник"}

`)); // Загружаем заявки и рендерим: week strip + сгруппированный инбокс const stripPlaceholder = el(`
`); const inboxSection = el(`
📥 Заявки
`); app.appendChild(stripPlaceholder); app.appendChild(inboxSection); if (caps.measurer) { try { const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, }), }); 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); } } /* ----------------- Группировка инбокса замерщика по дням ----------------- */ 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 = ""; 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.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))}
`); 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)); } } 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: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, measurement_id: measurementId, scheduled_at: iso, }), }); const data = await res.json(); if (data.error) { if (errorEl) errorEl.textContent = "Ошибка: " + data.error; return; } haptic && haptic("success"); tg?.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: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, 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: 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();