diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 82ecb53..955ac6f 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -147,6 +147,7 @@ async def _dispatch_post(request: Request): "assembly_set_kitchen_price": _handle_assembly_set_kitchen_price, "sign_request_create": _handle_sign_request_create, "sign_request_submit": _handle_sign_request_submit, + "managers_list": _handle_managers_list, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -1577,6 +1578,26 @@ def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)} +def _handle_managers_list(body: dict[str, Any]) -> dict[str, Any]: + """Список всех менеджеров — для dropdown «передать менеджеру».""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "only_manager"} + managers = sheets.list_users_with_role("manager") + # Исключаем самого себя (нет смысла передавать себе) + managers = [m for m in managers if str(m.get("tg_id", "")) != str(tg_id)] + return {"ok": True, "managers": managers} + + def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: """Менеджер создаёт ЗАЯВКУ на замер (без замеров — пустая заготовка). body: {initData, client_name, client_phone, address, assigned_to_tg_id?, notes?, urgent?} @@ -1620,10 +1641,22 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: if not assigned_user or not sheets.has_role(assigned_user, "measurer"): return {"error": "assigned_not_measurer"} + # Передать другому менеджеру (любой менеджер может переслать заявку коллеге) + target_manager_id = str(body.get("target_manager_tg_id") or "").strip() + effective_manager_tg_id = tg_id # по умолчанию — текущий + if target_manager_id and target_manager_id != str(tg_id): + try: + target_mgr_user = sheets.find_user(int(target_manager_id)) + except (TypeError, ValueError): + target_mgr_user = None + if target_mgr_user and sheets.has_role(target_mgr_user, "manager"): + effective_manager_tg_id = target_manager_id + # Если целевой пользователь не найден или не менеджер — молча игнорируем + measurement_id = _short_id() sheets.append_named_row("Measurements", _row_for_measurement( measurement_id, _now_iso(), - manager_tg_id=tg_id, + manager_tg_id=effective_manager_tg_id, filled_by="request", status="requested", assigned_to_tg_id=assigned_to, @@ -1638,6 +1671,18 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]: preferred_note=preferred_note, )) + # Уведомляем целевого менеджера если передали ему + if effective_manager_tg_id != str(tg_id): + tg.send_message( + int(effective_manager_tg_id), + f"📋 Вам передана заявка на замер\n\n" + f"Клиент: {client_name}\n" + f"Телефон: {client_phone}\n" + f"Адрес: {address or '—'}\n" + f"От: {user.get('full_name') or tg_id}\n\n" + f"Откройте кабинет — заявка уже в вашем списке." + ) + # Уведомление назначенному замерщику if assigned_to: note_line = f"\nПримечание: {preferred_note}" if preferred_note else "" diff --git a/miniapp/assets/request.js b/miniapp/assets/request.js index 05981df..ef8ce43 100644 --- a/miniapp/assets/request.js +++ b/miniapp/assets/request.js @@ -1,247 +1,51 @@ /* ============================================================ Заявка на замер — менеджер создаёт, замерщику в инбокс + v20260518p — поиск по клиентам + передача менеджеру ============================================================ */ const MeasurementRequest = (function () { let root = null; let state = { - client_name: "", + // Клиент + client_id: null, // ключ из списка (client_name+phone) — если выбрали + client_name: "", client_phone: "", - address: "", - assigned_to_tg_id: "", - // Одно поле «Примечание» — рекомендации по дате замера + особенности. - // Замерщик увидит это в карточке заявки и согласует точное время с клиентом. + address: "", + // Назначение + assigned_to_tg_id: "", + target_manager_tg_id: "", + // Прочее preferred_note: "", + urgent: false, }; - let measurers = []; + let allClients = []; // [{client_name, client_phone, address, client_tg_id}] + let measurers = []; + let managers = []; + let clientMode = "search"; // "search" | "selected" | "new" - async function _fetchWithTimeout(url, body, timeoutMs = 15000) { + /* ── API ──────────────────────────────────────────────────── */ + async function _api(path, body = {}) { const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const t = setTimeout(() => ctrl.abort(), 15000); try { - const res = await fetch(url, { method: "POST", signal: ctrl.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: Platform.initData, + initDataUnsafe: Platform.initDataUnsafe, + ...body, + }), + }); + if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); return await res.json(); } catch (e) { - if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз"); + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); throw e; - } finally { - clearTimeout(timer); - } - } - - function mount(container) { - root = container; - document.body.classList.remove("has-bottom-nav"); - const oldNav = document.getElementById("bottom-nav"); - if (oldNav) oldNav.remove(); - state = { - client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", - preferred_note: "", - }; - // Prefill из карточки клиента (sessionStorage перед navigate) - try { - const raw = sessionStorage.getItem("prefillClient"); - if (raw) { - const pre = JSON.parse(raw); - if (pre.name) state.client_name = pre.name; - if (pre.phone) state.client_phone = pre.phone; - sessionStorage.removeItem("prefillClient"); - } - } catch (e) {} - render(); - loadMeasurers(); - } - - function render() { - if (!root) return; - root.innerHTML = ""; - root.appendChild(headerEl("Новая заявка на замер", "#/")); - - const form = el(` -
-

Заявка
на замер

-

Заполните данные клиента — замерщик получит уведомление в Telegram и согласует дату.

- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
-
- `); - root.appendChild(form); - - bindInputs(form); - form.querySelector("#submit").addEventListener("click", () => onSubmit(form)); - } - - function bindInputs(node) { - node.querySelectorAll("[data-bind]").forEach(inp => { - inp.addEventListener("input", e => { - state[e.target.dataset.bind] = e.target.value; - }); - inp.addEventListener("change", e => { - state[e.target.dataset.bind] = e.target.value; - }); - }); - } - - async function loadMeasurers() { - try { - const data = await _fetchWithTimeout(`${BACKEND_URL}/api/staff_list`, { - initData: tg?.initData || "", role: "measurer", - }); - measurers = data.staff || []; - const sel = document.getElementById("measurerSelect"); - const hint = document.getElementById("measurerHint"); - if (!sel) return; - if (!measurers.length) { - sel.innerHTML = ``; - sel.disabled = true; - if (hint) hint.textContent = "Сначала выдайте кому-нибудь роль measurer через /grant_role"; - return; - } - sel.disabled = false; - sel.innerHTML = `` + - measurers.map(m => ``).join(""); - } catch (e) { - const sel = document.getElementById("measurerSelect"); - if (sel) sel.innerHTML = ``; - } - } - - async function onSubmit(form) { - const btn = form.querySelector("#submit"); - const result = form.querySelector("#submitResult"); - - // Валидация - form.querySelector("#errName").textContent = ""; - form.querySelector("#errPhone").textContent = ""; - const name = (state.client_name || "").trim(); - const phone = (state.client_phone || "").trim(); - if (!name) { - form.querySelector("#errName").textContent = "Укажите имя клиента"; - return; - } - if (phone.replace(/\D/g, "").length < 10) { - form.querySelector("#errPhone").textContent = "Слишком короткий номер"; - return; - } - - btn.disabled = true; - btn.innerHTML = ' создаём...'; - result.innerHTML = ""; - - try { - const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_request`, { - initData: tg?.initData || "", - initDataUnsafe: tg?.initDataUnsafe || null, - client_name: name, - client_phone: phone, - address: state.address || "", - assigned_to_tg_id: state.assigned_to_tg_id || "", - preferred_note: state.preferred_note || "", - preferred_type: "tbd", - }); - if (data.error) { - result.innerHTML = `
Ошибка: ${data.error}
`; - btn.disabled = false; - btn.textContent = "Попробовать снова"; - return; - } - - haptic && haptic("success"); - const assignedTo = state.assigned_to_tg_id - ? measurers.find(m => String(m.tg_id) === String(state.assigned_to_tg_id)) - : null; - - result.innerHTML = ` -
-
${ICONS.check}
-
-
Заявка создана
-
- ID #${(data.id || "").slice(0, 6)}${assignedTo ? " · Замерщик уведомлён в Telegram" : " · Без назначения"} -
-
-
-
- - -
- `; - form.querySelector("#newOne")?.addEventListener("click", () => mount(root)); - form.querySelector("#toHome")?.addEventListener("click", () => { - location.hash = ""; - if (typeof routeByHash === "function") routeByHash(); - }); - } catch (e) { - result.innerHTML = `
Сеть: ${e.message}
`; - btn.disabled = false; - btn.textContent = "Попробовать снова"; - } - } - - function headerEl(title, backHref) { - const h = el(` -
- -
${escHtml(title)}
-
-
- `); - h.querySelector(".podbor-back").addEventListener("click", () => { - if (backHref) location.hash = backHref; - else { - location.hash = ""; - if (typeof routeByHash === "function") routeByHash(); - } - }); - return h; + } finally { clearTimeout(t); } } + /* ── Helpers ─────────────────────────────────────────────── */ function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(/`); + + // ── Заголовок ──────────────────────────────────────────── + wrap.appendChild(el(` +
+

Заявка
на замер

+

Замерщик получит уведомление в Telegram и согласует дату с клиентом.

+
+ `)); + + // ── Блок клиента ───────────────────────────────────────── + const clientBlock = el(`
`); + wrap.appendChild(clientBlock); + _renderClientBlock(clientBlock); + + // ── Замерщик ───────────────────────────────────────────── + const assignBlock = el(` +
+ +
+ `); + wrap.appendChild(assignBlock); + + // ── Передать менеджеру ──────────────────────────────────── + const mgrBlock = el(` +
+ +
+ `); + wrap.appendChild(mgrBlock); + + // ── Примечание ─────────────────────────────────────────── + wrap.appendChild(el(` +
+ +
+ `)); + + // ── CTA ─────────────────────────────────────────────────── + wrap.appendChild(el(` +
+ +
+
+ `)); + + root.appendChild(wrap); + _bindWrap(wrap); + } + + /* ── Client block ────────────────────────────────────────── */ + function _renderClientBlock(container) { + container.innerHTML = ""; + + if (clientMode === "selected") { + // Карточка выбранного клиента + container.appendChild(el(` +
+
Клиент
+
+
+
${escHtml(state.client_name)}
+
${escHtml(maskPhone(state.client_phone))}
+
+ +
+
+ +
+
+ `)); + container.querySelector("#rq-clear-client").addEventListener("click", () => { + state.client_id = null; state.client_name = ""; state.client_phone = ""; state.address = ""; + clientMode = "search"; + _renderClientBlock(container); + }); + container.querySelector("#rq-address").addEventListener("input", e => { + state.address = e.target.value; + }); + + } else if (clientMode === "new") { + // Форма нового клиента + container.appendChild(el(` +
+
+ Новый клиент + +
+
+ +
+
+ +
+
+ +
+
+ `)); + container.querySelector("#rq-new-name").addEventListener("input", e => { state.client_name = e.target.value; }); + container.querySelector("#rq-new-phone").addEventListener("input", e => { state.client_phone = e.target.value; }); + container.querySelector("#rq-new-address").addEventListener("input", e => { state.address = e.target.value; }); + container.querySelector("#rq-back-search").addEventListener("click", () => { + state.client_name = ""; state.client_phone = ""; state.address = ""; + clientMode = "search"; + _renderClientBlock(container); + }); + + } else { + // Режим поиска (default) + const searchWrap = el(` +
+ + +
+ `); + container.appendChild(searchWrap); + + const input = searchWrap.querySelector("#rq-search"); + const dropdown = searchWrap.querySelector("#rq-dropdown"); + + input.addEventListener("input", () => _filterClients(input.value, dropdown, container)); + input.addEventListener("focus", () => { + if (input.value.trim() || !allClients.length) return; + _showDropdown(allClients.slice(0, 6), dropdown, container, input); + }); + document.addEventListener("click", function _outsideClick(e) { + if (!searchWrap.contains(e.target)) { + dropdown.style.display = "none"; + document.removeEventListener("click", _outsideClick); + } + }); + } + } + + function _filterClients(query, dropdown, container) { + const q = query.trim().toLowerCase(); + if (!q) { dropdown.style.display = "none"; return; } + const matches = allClients.filter(c => + (c.client_name || "").toLowerCase().includes(q) || + (c.client_phone || "").replace(/\D/g, "").includes(q.replace(/\D/g, "")) + ).slice(0, 6); + _showDropdown(matches, dropdown, container, document.getElementById("rq-search"), q); + } + + function _showDropdown(list, dropdown, container, input, query = "") { + dropdown.innerHTML = ""; + dropdown.style.display = ""; + + list.forEach(c => { + const item = el(` +
+
+
${escHtml(c.client_name || "—")}
+
+ ${escHtml(maskPhone(c.client_phone))} + ${c.address ? " · " + escHtml(c.address.slice(0, 30)) : ""} +
+
+
+
+ `); + item.addEventListener("mousedown", e => e.preventDefault()); // не теряем focus + item.addEventListener("click", () => { + state.client_id = c.client_name + "|" + c.client_phone; + state.client_name = c.client_name || ""; + state.client_phone = c.client_phone || ""; + state.address = c.address || ""; + clientMode = "selected"; + dropdown.style.display = "none"; + _renderClientBlock(container); + }); + dropdown.appendChild(item); + }); + + // «Создать нового клиента» + const newBtn = el(` +
+ Создать нового клиента +
+ `); + newBtn.addEventListener("mousedown", e => e.preventDefault()); + newBtn.addEventListener("click", () => { + if (input) state.client_name = input.value.trim(); + clientMode = "new"; + dropdown.style.display = "none"; + _renderClientBlock(container); + }); + dropdown.appendChild(newBtn); + } + + /* ── Measurer & Manager selects ──────────────────────────── */ + function _renderMeasurerSelect() { + const sel = document.getElementById("rq-measurer"); + const hint = document.getElementById("rq-measurer-hint"); + if (!sel) return; + if (!measurers.length) { + sel.innerHTML = ``; + sel.disabled = true; + if (hint) hint.textContent = "Выдайте кому-нибудь роль measurer через /grant_role"; + return; + } + sel.disabled = false; + sel.innerHTML = + `` + + measurers.map(m => + `` + ).join(""); + } + + function _renderManagerSelect() { + const sel = document.getElementById("rq-manager"); + if (!sel) return; + if (!managers.length) { + sel.innerHTML = ``; + sel.disabled = true; + return; + } + sel.disabled = false; + sel.innerHTML = + `` + + managers.map(m => + `` + ).join(""); + } + + /* ── Bind ─────────────────────────────────────────────────── */ + function _bindWrap(wrap) { + wrap.querySelector("#rq-measurer")?.addEventListener("change", e => { + state.assigned_to_tg_id = e.target.value; + }); + wrap.querySelector("#rq-manager")?.addEventListener("change", e => { + state.target_manager_tg_id = e.target.value; + }); + wrap.querySelector("#rq-note")?.addEventListener("input", e => { + state.preferred_note = e.target.value; + }); + wrap.querySelector("#rq-submit")?.addEventListener("click", () => _onSubmit(wrap)); + } + + /* ── Submit ──────────────────────────────────────────────── */ + async function _onSubmit(wrap) { + // Валидация + const container = wrap.querySelector("#rq-client-block"); + const errName = container?.querySelector("#rq-err-name"); + const errPhone = container?.querySelector("#rq-err-phone"); + if (errName) errName.textContent = ""; + if (errPhone) errPhone.textContent = ""; + + const name = state.client_name.trim(); + const phone = state.client_phone.trim(); + + if (!name) { + if (errName) errName.textContent = "Укажите имя клиента"; + else Platform.showAlert("Укажите имя клиента"); + return; + } + if (phone.replace(/\D/g, "").length < 10) { + if (errPhone) errPhone.textContent = "Слишком короткий номер"; + else Platform.showAlert("Укажите телефон клиента"); + return; + } + + const btn = wrap.querySelector("#rq-submit"); + const result = wrap.querySelector("#rq-result"); + btn.disabled = true; + btn.innerHTML = ' создаём...'; + result.innerHTML = ""; + + try { + const data = await _api("measurement_request", { + client_name: name, + client_phone: phone, + address: state.address || "", + assigned_to_tg_id: state.assigned_to_tg_id || "", + target_manager_tg_id: state.target_manager_tg_id || "", + preferred_note: state.preferred_note || "", + preferred_type: "tbd", + }); + + if (data.error) { + result.innerHTML = `
Ошибка: ${escHtml(data.error)}
`; + btn.disabled = false; btn.textContent = "Попробовать снова"; + return; + } + + haptic && haptic("success"); + + const assignedTo = state.assigned_to_tg_id + ? measurers.find(m => String(m.tg_id) === String(state.assigned_to_tg_id)) + : null; + const handedTo = state.target_manager_tg_id + ? managers.find(m => String(m.tg_id) === String(state.target_manager_tg_id)) + : null; + + result.innerHTML = ` +
+
+
+
Заявка создана
+
+ #${(data.id || "").slice(0, 6)} + ${assignedTo ? " · Замерщик уведомлён" : ""} + ${handedTo ? ` · Передана ${escHtml(handedTo.full_name || "менеджеру")}` : ""} +
+
+
+
+ + +
+ `; + result.querySelector("#rq-new")?.addEventListener("click", () => mount(root)); + result.querySelector("#rq-home")?.addEventListener("click", () => { + location.hash = ""; + if (typeof routeByHash === "function") routeByHash(); + }); + } catch (e) { + result.innerHTML = `
Сеть: ${escHtml(e.message)}
`; + btn.disabled = false; btn.textContent = "Попробовать снова"; + } + } + + /* ── Header ──────────────────────────────────────────────── */ + function _headerEl() { + const h = el(` +
+ +
Новая заявка на замер
+
+
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + location.hash = ""; + if (typeof routeByHash === "function") routeByHash(); + }); + return h; + } + return { mount }; })(); diff --git a/miniapp/index.html b/miniapp/index.html index 8cfe2e0..1a2973a 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -43,7 +43,7 @@ - +