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(`
-
- `);
- 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(`
-
- `);
- 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 @@
-
+