// ЗОВ 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://api.wasrusgen1.pro";
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: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
{ icon: "wrench", title: "Сборки", subtitle: "Заявки на сборку", href: "#/assembly" },
];
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);
// Контейнер для отгрузок с завода (под активными проектами)
const shipmentsContainer = el(`
`);
app.appendChild(shipmentsContainer);
// Контейнер для поступлений на склад СПб
const arrivalsContainer = el(`
`);
app.appendChild(arrivalsContainer);
renderBottomNav("home", { unreadChats: 0 });
// Контейнер для карточек «Замер готов — что делать с подбором?»
const pendingContainer = el(`
`);
app.insertBefore(pendingContainer, todayContainer);
// Параллельно грузим реальные данные (измерения + pending — критичные)
// Складские данные грузим отдельно, чтобы ошибка Drive не ломала весь дашборд
try {
const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
const [resM, resP] = await Promise.all([
fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }),
]);
const data = await resM.json();
const pendingData = await resP.json();
renderManagerPending(pendingContainer, pendingData.pending || []);
renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl);
renderManagerProjects(projectsContainer, data.measurements || []);
// Складские данные — не критичны; грузим после, ошибка не ломает дашборд
const authBodyStr = JSON.stringify(authBody);
Promise.all([
fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
]).then(([shipmentsData, arrivalsData]) => {
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода");
renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб");
}).catch(() => { /* тихо — дашборд уже отрисован */ });
} catch (e) {
todayContainer.innerHTML = `Не удалось загрузить данные: ${escHtml(e.message)}
`;
}
}
/* ----------------- Менеджер: карточки «Замер готов — подбор?» ----------------- */
function renderManagerPending(container, pending) {
container.innerHTML = "";
if (!pending.length) return;
container.appendChild(el(`
✅ Замеры готовы · ${pending.length}
`));
for (const p of pending) {
const isLater = p.decision === "later";
const card = el(`
✅
${escHtml(p.client_name || "Без имени")}
Замер выполнен · ${escHtml(p.address || "адрес не указан")}
${isLater ? "Снова: " : ""}Клиенту потребуется помощь с подбором техники?
Да, поможем
Нет
Позже
`);
card.querySelectorAll("button[data-act]").forEach(btn => {
btn.addEventListener("click", () => handlePodborDecision(p, btn.dataset.act, card));
});
container.appendChild(card);
}
}
async function handlePodborDecision(item, act, card) {
const decisionMap = { yes: "needed", no: "not_needed", later: "later" };
const decision = decisionMap[act];
if (!decision) return;
const resultEl = card.querySelector(".pending-result");
if (resultEl) resultEl.textContent = "Сохраняем...";
card.querySelectorAll("button").forEach(b => b.disabled = true);
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_decision`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
measurement_id: item.id,
decision,
}),
});
const data = await res.json();
if (data.error) {
if (resultEl) resultEl.innerHTML = `Ошибка: ${escHtml(data.error)} `;
card.querySelectorAll("button").forEach(b => b.disabled = false);
return;
}
haptic && haptic("success");
if (decision === "needed") {
// Переходим в подбор техники с pre-fill из клиента
sessionStorage.setItem("prefillClient", JSON.stringify({
name: item.client_name, phone: item.client_phone,
}));
location.hash = `#/podbor?client_name=${encodeURIComponent(item.client_name || "")}&client_phone=${encodeURIComponent(item.client_phone || "")}`;
} else {
// Анимируем удаление карточки
card.style.transition = "opacity 0.25s, transform 0.25s";
card.style.opacity = "0";
card.style.transform = "translateX(20px)";
setTimeout(() => card.remove(), 250);
}
} catch (e) {
if (resultEl) resultEl.innerHTML = `Сеть: ${escHtml(e.message)} `;
card.querySelectorAll("button").forEach(b => b.disabled = false);
}
}
function renderManagerToday(container, measurements, firstName, greetingEl) {
const today = _startOfDay(new Date());
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
// Сегодня = scheduled_at сегодня и не completed
const todayEvents = [];
const overdueEvents = [];
const noDateEvents = [];
for (const m of measurements) {
if (m.status === "completed") continue;
if (m.scheduled_at) {
const d = new Date(m.scheduled_at);
if (_startOfDay(d).getTime() === today.getTime()) {
todayEvents.push(m);
} else if (d < new Date()) {
overdueEvents.push(m);
}
} else if (m.status === "requested") {
// Заявка без даты — нужно подсказать замерщику
noDateEvents.push(m);
}
}
todayEvents.sort((a, b) => (a.scheduled_at || "").localeCompare(b.scheduled_at || ""));
// Обновляем приветствие
const cnt = todayEvents.length;
let tail;
if (cnt === 0) {
tail = overdueEvents.length
? `${overdueEvents.length} ${pluralRu(overdueEvents.length, ["просрочка", "просрочки", "просрочек"])}`
: "ничего на сегодня";
} else {
const word = pluralRu(cnt, ["замер", "замера", "замеров"]);
tail = `${cnt === 1 ? "один" : cnt} ${word} сегодня`;
}
const headline = greetingEl.querySelector("#greetingHeadline");
if (headline) headline.innerHTML = `${escHtml(firstName)},${escHtml(tail)} `;
container.innerHTML = "";
// HERO — первое событие сегодня
if (todayEvents.length > 0) {
const m = todayEvents[0];
const d = new Date(m.scheduled_at);
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, "");
const hero = el(`
На сегодня — ${hh}:${mi}
ЗАМЕР
${escHtml(m.client_name || "Без имени")}
${escHtml(m.address || "адрес не указан")}
`);
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 renderManagerShipments(container, groups, label = "📦 Отгрузки") {
container.innerHTML = "";
if (!groups || !groups.length) return;
// Показываем последние 3 партии (ближайшие по дате отгрузки с завода)
const visible = groups.slice(-3);
const totalItems = visible.reduce((s, g) => s + g.count, 0);
container.appendChild(el(`
${escHtml(label)} · ${totalItems} поз.
`));
for (const group of visible) {
const zakazBadge = group.count_zakazov
? `Заказов ${group.count_zakazov} ` : "";
const dozBadge = group.count_dozakazov
? `Дозаказов ${group.count_dozakazov} ` : "";
const groupEl = el(`
${escHtml(group.factory_date)}
${zakazBadge}${dozBadge}
`);
const rowsEl = groupEl.querySelector(".ship-rows");
for (const item of group.items) {
const typeClass = item.tovar.startsWith("Доз") ? "dozakaz" : "zakaz";
const typeMark = item.tovar.startsWith("Доз") ? "Дозаказ" : "Заказ";
const delivStr = item.delivery_date ? `📬 ${escHtml(item.delivery_date)}` : "";
const assembler = item.assembler ? `🔧 ${escHtml(item.assembler)}` : "";
const places = item.places ? `📦 ${escHtml(item.places)} м.` : "";
const meta = [delivStr, assembler, places].filter(Boolean).join(" · ");
const furn = item.furn_spb ? `Фурн: ${escHtml(item.furn_spb)} ` : "";
const pan = item.panels_spb ? `Пан: ${escHtml(item.panels_spb)} ` : "";
const numStr = item.num ? `#${escHtml(item.num)} ` : "";
const contractStr = item.contract ? `Дог ${escHtml(item.contract)}` : "";
const noteStr = item.note ? `${escHtml(item.note)}
` : "";
rowsEl.appendChild(el(`
${typeMark}
${numStr}${contractStr}
${meta ? `
${meta}
` : ""}
${furn || pan ? `
${furn}${pan}
` : ""}
${noteStr}
`));
}
container.appendChild(groupEl);
}
}
function renderBottomNav(active, opts = {}) {
// Удаляем предыдущий, если есть
const old = document.getElementById("bottom-nav");
if (old) old.remove();
const tabs = [
{ key: "home", icon: "home", label: "Главная" },
{ key: "projects", icon: "folder", label: "Проекты" },
{ key: "fab", icon: "plus", label: "" },
{ key: "chat", icon: "chat", label: "Чат", badge: opts.unreadChats || 0 },
{ key: "profile", icon: "user", label: "Профиль" },
];
const nav = el(` `);
tabs.forEach(t => {
const isFab = t.key === "fab";
const isActive = t.key === active;
const btn = el(`
${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: "Подобрать технику", href: "#/c/proposal" },
{ icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" },
],
},
{
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);
}
// Сборки — отдельный блок, доступен мастеру (measurer ∨ assembler)
if (caps.measurer || caps.assembler) {
const assemblySection = el(`
`);
app.appendChild(assemblySection);
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
}
}
async function renderStaffAssemblies(container) {
try {
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
}),
});
const data = await res.json();
if (data.error) {
container.innerHTML = `Ошибка: ${escHtml(data.error)}
`;
return;
}
const items = (data.assemblies || []).filter(a => a.status !== "completed" && a.status !== "cancelled");
if (!items.length) {
container.innerHTML = `Сборок нет
`;
return;
}
container.innerHTML = "";
for (const a of items) {
const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "— дата не назначена";
const statusLabel = {
created: "📝 создана",
scheduled: "📅 назначена",
in_progress: "🔧 в работе",
}[a.status] || a.status;
const card = el(`
${statusLabel}
${escHtml(dateStr)}
${escHtml(a.client_name || "Без имени")}
${escHtml(a.address || "адрес не указан")}
${a.scope_of_work ? `${escHtml(a.scope_of_work.slice(0, 100))}${a.scope_of_work.length > 100 ? "…" : ""}
` : ""}
`);
card.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/assembly/${a.id}`;
});
container.appendChild(card);
}
} catch (e) {
container.innerHTML = `Сеть: ${escHtml(e.message)}
`;
}
}
/* ----------------- Группировка инбокса замерщика по дням ----------------- */
function _startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function _daysBetween(a, b) {
return Math.round((_startOfDay(b) - _startOfDay(a)) / 86400000);
}
function _groupForMeasurement(m, today, weekEnd) {
if (!m.scheduled_at) {
// Без даты — отделяем requested от scheduled (по идее scheduled без даты быть не должно)
return { key: "no_date", title: "📞 Без даты — нужно согласовать", order: 5 };
}
const d = new Date(m.scheduled_at);
const diff = _daysBetween(today, d);
if (diff < 0) return { key: "overdue", title: "⚠️ Просрочено", order: 0 };
if (diff === 0) return { key: "today", title: "🔥 Сегодня", order: 1 };
if (diff === 1) return { key: "tomorrow", title: "📅 Завтра", order: 2 };
if (d <= weekEnd) return { key: "this_week", title: "🗓️ На неделе", order: 3 };
return { key: "later", title: "📆 Позже", order: 4 };
}
function renderGroupedInbox(container, measurements) {
container.innerHTML = "";
if (!measurements.length) {
container.innerHTML = `
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
`;
return;
}
const today = _startOfDay(new Date());
// Конец этой недели: воскресенье вечером
const weekEnd = new Date(today);
const dayIdx = (today.getDay() + 6) % 7; // 0 = Пн, 6 = Вс
weekEnd.setDate(today.getDate() + (6 - dayIdx));
weekEnd.setHours(23, 59, 59, 999);
// Группируем
const groups = new Map();
for (const m of measurements) {
const g = _groupForMeasurement(m, today, weekEnd);
if (!groups.has(g.key)) groups.set(g.key, { ...g, items: [] });
groups.get(g.key).items.push(m);
}
// Сортируем группы и внутри — по дате
const sortedGroups = [...groups.values()].sort((a, b) => a.order - b.order);
for (const g of sortedGroups) {
g.items.sort((a, b) => (a.scheduled_at || "").localeCompare(b.scheduled_at || ""));
const groupEl = el(`
${g.title}${g.items.length}
`);
const list = groupEl.querySelector(".inbox-group-list");
g.items.forEach(m => list.appendChild(renderInboxItem(m, g.key)));
container.appendChild(groupEl);
}
}
/* ----------------- Week strip — загрузка по дням ----------------- */
function renderWeekStrip(measurements) {
const today = _startOfDay(new Date());
const dayIdx = (today.getDay() + 6) % 7; // Пн = 0
const monday = new Date(today);
monday.setDate(today.getDate() - dayIdx);
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(d);
}
// Считаем сколько замеров на каждый день
const countByDay = days.map(d => {
const start = _startOfDay(d).getTime();
const end = start + 86400000;
return measurements.filter(m => {
if (!m.scheduled_at) return false;
const t = new Date(m.scheduled_at).getTime();
return t >= start && t < end;
}).length;
});
const maxCount = Math.max(1, ...countByDay);
const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const section = el(`
${monday.getDate()}–${days[6].getDate()} ${monday.toLocaleString("ru-RU", { month: "long" })}
${days.map((d, i) => {
const cnt = countByDay[i];
const heightPct = cnt ? Math.round((cnt / maxCount) * 100) : 0;
const isToday = _startOfDay(d).getTime() === today.getTime();
const isPast = _startOfDay(d).getTime() < today.getTime();
const loadClass = cnt >= 5 ? "load-hot" : cnt >= 3 ? "load-mid" : cnt > 0 ? "load-low" : "load-zero";
return `
${dayNames[i]}
${d.getDate()}
${cnt || "—"}
`;
}).join("")}
`);
return section;
}
/* ----------------- Карточка заявки в инбоксе ----------------- */
function renderInboxItem(m, groupKey) {
// Когда: точное время если назначено + день недели для не-today
let timeLine;
if (m.scheduled_at) {
const d = new Date(m.scheduled_at);
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
if (groupKey === "today" || groupKey === "tomorrow") {
timeLine = `${hh}:${mi}`;
} else if (groupKey === "overdue") {
timeLine = `${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`;
} else {
const dayNames = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
timeLine = `${dayNames[d.getDay()]} ${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`;
}
} else {
timeLine = formatPreferredHuman(m);
}
const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, "");
const callHref = phoneClean ? `tel:${phoneClean}` : "";
const item = el(`
${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))}
${m.gcal_event_url ? `` : ""}
Изменить дату
`);
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 (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
hideSplash();
return;
}
if (location.hash.startsWith("#/c/proposal")) {
app.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
if (typeof Proposals !== "undefined") {
Proposals.mountClient(app);
} else {
app.innerHTML = `Модуль подбора не загружен
`;
}
hideSplash();
return;
}
if (location.hash.startsWith("#/c/contract")) {
app.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNavC = document.getElementById("bottom-nav");
if (oldNavC) oldNavC.remove();
if (typeof Proposals !== "undefined") {
Proposals.mountContractReview(app);
} else {
app.innerHTML = `Модуль не загружен
`;
}
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 if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
} else if (location.hash.startsWith("#/c/proposal")) {
app.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav2 = document.getElementById("bottom-nav");
if (oldNav2) oldNav2.remove();
if (typeof Proposals !== "undefined") {
Proposals.mountClient(app);
} else {
app.innerHTML = `Модуль подбора не загружен
`;
}
} else if (location.hash.startsWith("#/c/contract")) {
app.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav3 = document.getElementById("bottom-nav");
if (oldNav3) oldNav3.remove();
if (typeof Proposals !== "undefined") {
Proposals.mountContractReview(app);
} else {
app.innerHTML = `Модуль не загружен
`;
}
} else {
// Главный экран по роли
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();