// ЗОВ 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(`
`);
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(`
${ICONS[qa.icon] || ""}
${qa.title}
${qa.subtitle}
`);
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 || "адрес не указан")}
`);
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(`
${escHtml(timeText)}
${escHtml(m.client_name || "—")}
${escHtml(m.address || "адрес не указан")}
${ICONS.chevron || "›"}
${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 || "адрес не указан")}
`);
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(`
${ICONS[t.icon] || ""}
${isFab || !t.label ? "" : `${t.label} `}
${t.badge ? `${t.badge} ` : ""}
`);
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 · CRM"}
${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(`
Кто вы?
Выберите роль — кабинет откроется одним тапом.
👤
Я менеджер
Веду клиентов и заказы
${ICONS.chevron || "›"}
🏠
Я клиент
Заказал кухню ЗОВ
${ICONS.chevron || "›"}
🔧
Я сотрудник
Замерщик или сборщик ЗОВ
${ICONS.chevron || "›"}
Свой выбор можно изменить позже в профиле.
`));
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(`
${escHtml(timeLine)}
${escHtml(m.client_name || "—")}
${escHtml(m.address || "адрес не указан")}
${ICONS.chevron || "›"}
${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 ? '● ' : ''}
${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();