mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:24:49 +00:00
feat: заявка на замер — поиск по клиентам + передача менеджеру
Frontend (request.js): - Поиск клиентов из списка менеджера (autocomplete dropdown) по имени или цифрам телефона, макс. 6 результатов - Режимы: search → selected (карточка + редактируемый адрес) или → new (ручной ввод ФИО/тел/адрес) - «Создать нового клиента» всегда в конце dropdown - Выпадающий список замерщиков (existing) - Новый select «Передать менеджеру» — передаёт заявку коллеге - Platform.initData / Platform.initDataUnsafe вместо tg?. Backend (main.py): - _handle_managers_list: список всех менеджеров кроме себя - _handle_measurement_request: поддержка target_manager_tg_id (заявка создаётся от имени целевого менеджера + уведомление) - Роут managers_list добавлен в handlers dict Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bd85b30aa5
commit
5ed11c00fa
@ -147,6 +147,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"assembly_set_kitchen_price": _handle_assembly_set_kitchen_price,
|
"assembly_set_kitchen_price": _handle_assembly_set_kitchen_price,
|
||||||
"sign_request_create": _handle_sign_request_create,
|
"sign_request_create": _handle_sign_request_create,
|
||||||
"sign_request_submit": _handle_sign_request_submit,
|
"sign_request_submit": _handle_sign_request_submit,
|
||||||
|
"managers_list": _handle_managers_list,
|
||||||
"proposal_brief": proposals_mod.handle_brief,
|
"proposal_brief": proposals_mod.handle_brief,
|
||||||
"proposal_create": proposals_mod.handle_create,
|
"proposal_create": proposals_mod.handle_create,
|
||||||
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
"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)}
|
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]:
|
def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Менеджер создаёт ЗАЯВКУ на замер (без замеров — пустая заготовка).
|
"""Менеджер создаёт ЗАЯВКУ на замер (без замеров — пустая заготовка).
|
||||||
body: {initData, client_name, client_phone, address, assigned_to_tg_id?, notes?, urgent?}
|
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"):
|
if not assigned_user or not sheets.has_role(assigned_user, "measurer"):
|
||||||
return {"error": "assigned_not_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()
|
measurement_id = _short_id()
|
||||||
sheets.append_named_row("Measurements", _row_for_measurement(
|
sheets.append_named_row("Measurements", _row_for_measurement(
|
||||||
measurement_id, _now_iso(),
|
measurement_id, _now_iso(),
|
||||||
manager_tg_id=tg_id,
|
manager_tg_id=effective_manager_tg_id,
|
||||||
filled_by="request",
|
filled_by="request",
|
||||||
status="requested",
|
status="requested",
|
||||||
assigned_to_tg_id=assigned_to,
|
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,
|
preferred_note=preferred_note,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Уведомляем целевого менеджера если передали ему
|
||||||
|
if effective_manager_tg_id != str(tg_id):
|
||||||
|
tg.send_message(
|
||||||
|
int(effective_manager_tg_id),
|
||||||
|
f"📋 <b>Вам передана заявка на замер</b>\n\n"
|
||||||
|
f"Клиент: <b>{client_name}</b>\n"
|
||||||
|
f"Телефон: <code>{client_phone}</code>\n"
|
||||||
|
f"Адрес: {address or '—'}\n"
|
||||||
|
f"От: {user.get('full_name') or tg_id}\n\n"
|
||||||
|
f"Откройте кабинет — заявка уже в вашем списке."
|
||||||
|
)
|
||||||
|
|
||||||
# Уведомление назначенному замерщику
|
# Уведомление назначенному замерщику
|
||||||
if assigned_to:
|
if assigned_to:
|
||||||
note_line = f"\nПримечание: {preferred_note}" if preferred_note else ""
|
note_line = f"\nПримечание: {preferred_note}" if preferred_note else ""
|
||||||
|
|||||||
@ -1,247 +1,51 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Заявка на замер — менеджер создаёт, замерщику в инбокс
|
Заявка на замер — менеджер создаёт, замерщику в инбокс
|
||||||
|
v20260518p — поиск по клиентам + передача менеджеру
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const MeasurementRequest = (function () {
|
const MeasurementRequest = (function () {
|
||||||
let root = null;
|
let root = null;
|
||||||
let state = {
|
let state = {
|
||||||
|
// Клиент
|
||||||
|
client_id: null, // ключ из списка (client_name+phone) — если выбрали
|
||||||
client_name: "",
|
client_name: "",
|
||||||
client_phone: "",
|
client_phone: "",
|
||||||
address: "",
|
address: "",
|
||||||
|
// Назначение
|
||||||
assigned_to_tg_id: "",
|
assigned_to_tg_id: "",
|
||||||
// Одно поле «Примечание» — рекомендации по дате замера + особенности.
|
target_manager_tg_id: "",
|
||||||
// Замерщик увидит это в карточке заявки и согласует точное время с клиентом.
|
// Прочее
|
||||||
preferred_note: "",
|
preferred_note: "",
|
||||||
|
urgent: false,
|
||||||
};
|
};
|
||||||
|
let allClients = []; // [{client_name, client_phone, address, client_tg_id}]
|
||||||
let measurers = [];
|
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 ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), 15000);
|
||||||
try {
|
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();
|
return await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
|
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally { clearTimeout(t); }
|
||||||
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(`
|
|
||||||
<section class="podbor-step">
|
|
||||||
<h2 class="display-title">Заявка<br><span class="accent">на замер</span></h2>
|
|
||||||
<p class="lede">Заполните данные клиента — замерщик получит уведомление в Telegram и согласует дату.</p>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">ФИО клиента *</span>
|
|
||||||
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович" autocomplete="name">
|
|
||||||
<span class="field-error" id="errName"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Телефон *</span>
|
|
||||||
<input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 921 555-12-34" autocomplete="tel">
|
|
||||||
<span class="field-hint">Минимум 10 цифр</span>
|
|
||||||
<span class="field-error" id="errPhone"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Адрес замера</span>
|
|
||||||
<input type="text" data-bind="address" value="${escAttr(state.address)}" placeholder="СПб, Просвещения 87, кв. 12">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Кому назначить</span>
|
|
||||||
<select data-bind="assigned_to_tg_id" id="measurerSelect">
|
|
||||||
<option value="">— Загрузка списка...</option>
|
|
||||||
</select>
|
|
||||||
<span class="field-hint" id="measurerHint">Замерщик получит DM с реквизитами заявки</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Примечание</span>
|
|
||||||
<textarea data-bind="preferred_note" rows="3" placeholder="например: эта неделя после звонка, не раньше вторника, удобно утром, газ/электро, особые условия доступа"></textarea>
|
|
||||||
<span class="field-hint">Рекомендации по дате + особенности. Точную дату согласует замерщик с клиентом.</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="podbor-cta-row">
|
|
||||||
<button class="btn-primary" id="submit">Создать заявку</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="submitResult" class="submit-result"></div>
|
|
||||||
</section>
|
|
||||||
`);
|
|
||||||
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 = `<option value="">— Замерщиков пока нет —</option>`;
|
|
||||||
sel.disabled = true;
|
|
||||||
if (hint) hint.textContent = "Сначала выдайте кому-нибудь роль measurer через /grant_role";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sel.disabled = false;
|
|
||||||
sel.innerHTML = `<option value="">— Не назначать (заберу сам)</option>` +
|
|
||||||
measurers.map(m => `<option value="${m.tg_id}">${escHtml(m.full_name || "?")} ${m.tg_username ? "(@" + m.tg_username + ")" : ""}</option>`).join("");
|
|
||||||
} catch (e) {
|
|
||||||
const sel = document.getElementById("measurerSelect");
|
|
||||||
if (sel) sel.innerHTML = `<option value="">— ошибка загрузки —</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = '<span class="spinner-inline"></span> создаём...';
|
|
||||||
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 = `<div class="error">Ошибка: ${data.error}</div>`;
|
|
||||||
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 = `
|
|
||||||
<div class="success">
|
|
||||||
<div class="success-icon">${ICONS.check}</div>
|
|
||||||
<div>
|
|
||||||
<div class="success-title">Заявка создана</div>
|
|
||||||
<div class="success-sub">
|
|
||||||
ID #${(data.id || "").slice(0, 6)}${assignedTo ? " · Замерщик уведомлён в Telegram" : " · Без назначения"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="podbor-cta-row" style="margin-top:16px;">
|
|
||||||
<button class="btn-secondary" id="newOne">Ещё заявка</button>
|
|
||||||
<button class="btn-primary" id="toHome">На главную</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
form.querySelector("#newOne")?.addEventListener("click", () => mount(root));
|
|
||||||
form.querySelector("#toHome")?.addEventListener("click", () => {
|
|
||||||
location.hash = "";
|
|
||||||
if (typeof routeByHash === "function") routeByHash();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = "Попробовать снова";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function headerEl(title, backHref) {
|
|
||||||
const h = el(`
|
|
||||||
<header class="podbor-header">
|
|
||||||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
|
||||||
<div class="podbor-title">${escHtml(title)}</div>
|
|
||||||
<div style="width:28px"></div>
|
|
||||||
</header>
|
|
||||||
`);
|
|
||||||
h.querySelector(".podbor-back").addEventListener("click", () => {
|
|
||||||
if (backHref) location.hash = backHref;
|
|
||||||
else {
|
|
||||||
location.hash = "";
|
|
||||||
if (typeof routeByHash === "function") routeByHash();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return h;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ─────────────────────────────────────────────── */
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s == null ? "" : s)
|
return String(s == null ? "" : s)
|
||||||
.replace(/&/g, "&").replace(/</g, "<")
|
.replace(/&/g, "&").replace(/</g, "<")
|
||||||
@ -249,5 +53,473 @@ const MeasurementRequest = (function () {
|
|||||||
}
|
}
|
||||||
function escAttr(s) { return escHtml(s); }
|
function escAttr(s) { return escHtml(s); }
|
||||||
|
|
||||||
|
function el(html) {
|
||||||
|
const t = document.createElement("template");
|
||||||
|
t.innerHTML = html.trim();
|
||||||
|
return t.content.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPhone(p) {
|
||||||
|
const d = (p || "").replace(/\D/g, "");
|
||||||
|
if (d.length < 4) return p;
|
||||||
|
return d.slice(0, 1) + "**" + d.slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mount ───────────────────────────────────────────────── */
|
||||||
|
function mount(container) {
|
||||||
|
root = container;
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
const oldNav = document.getElementById("bottom-nav");
|
||||||
|
if (oldNav) oldNav.remove();
|
||||||
|
_resetState();
|
||||||
|
|
||||||
|
// Prefill из sessionStorage (из карточки клиента)
|
||||||
|
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");
|
||||||
|
clientMode = "new"; // уже знаем имя — режим нового клиента
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
render();
|
||||||
|
_loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resetState() {
|
||||||
|
state = {
|
||||||
|
client_id: null, client_name: "", client_phone: "", address: "",
|
||||||
|
assigned_to_tg_id: "", target_manager_tg_id: "",
|
||||||
|
preferred_note: "", urgent: false,
|
||||||
|
};
|
||||||
|
clientMode = "search";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Load: clients + measurers + managers ─────────────────── */
|
||||||
|
async function _loadAll() {
|
||||||
|
// Параллельно
|
||||||
|
const [cRes, mRes, mgRes] = await Promise.allSettled([
|
||||||
|
_api("clients"),
|
||||||
|
_api("staff_list", { role: "measurer" }),
|
||||||
|
_api("managers_list"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cRes.status === "fulfilled" && !cRes.value.error) {
|
||||||
|
allClients = cRes.value.clients || [];
|
||||||
|
}
|
||||||
|
if (mRes.status === "fulfilled" && !mRes.value.error) {
|
||||||
|
measurers = mRes.value.staff || [];
|
||||||
|
}
|
||||||
|
if (mgRes.status === "fulfilled" && !mgRes.value.error) {
|
||||||
|
managers = mgRes.value.managers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderMeasurerSelect();
|
||||||
|
_renderManagerSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render ──────────────────────────────────────────────── */
|
||||||
|
function render() {
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(_headerEl());
|
||||||
|
|
||||||
|
const wrap = el(`<div class="podbor-screen" style="padding-bottom:24px;"></div>`);
|
||||||
|
|
||||||
|
// ── Заголовок ────────────────────────────────────────────
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div style="padding:16px 16px 0;">
|
||||||
|
<h2 class="display-title">Заявка<br><span class="accent">на замер</span></h2>
|
||||||
|
<p class="lede" style="margin-top:4px;">Замерщик получит уведомление в Telegram и согласует дату с клиентом.</p>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
// ── Блок клиента ─────────────────────────────────────────
|
||||||
|
const clientBlock = el(`<div id="rq-client-block" style="padding:0 16px;"></div>`);
|
||||||
|
wrap.appendChild(clientBlock);
|
||||||
|
_renderClientBlock(clientBlock);
|
||||||
|
|
||||||
|
// ── Замерщик ─────────────────────────────────────────────
|
||||||
|
const assignBlock = el(`
|
||||||
|
<div style="padding:0 16px;margin-top:14px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Замерщик</span>
|
||||||
|
<select id="rq-measurer" style="width:100%;">
|
||||||
|
<option value="">— Загружаем список...</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-hint" id="rq-measurer-hint">Замерщик получит уведомление в Telegram</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
wrap.appendChild(assignBlock);
|
||||||
|
|
||||||
|
// ── Передать менеджеру ────────────────────────────────────
|
||||||
|
const mgrBlock = el(`
|
||||||
|
<div style="padding:0 16px;margin-top:14px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Передать менеджеру</span>
|
||||||
|
<select id="rq-manager" style="width:100%;">
|
||||||
|
<option value="">— Оставить себе —</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-hint">Заявка появится в списке выбранного менеджера</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
wrap.appendChild(mgrBlock);
|
||||||
|
|
||||||
|
// ── Примечание ───────────────────────────────────────────
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div style="padding:0 16px;margin-top:14px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Примечание для замерщика</span>
|
||||||
|
<textarea id="rq-note" rows="3"
|
||||||
|
placeholder="удобная дата, особенности доступа, газ/электро, парковка..."
|
||||||
|
style="width:100%;box-sizing:border-box;"></textarea>
|
||||||
|
<span class="field-hint">Точную дату согласует замерщик с клиентом напрямую</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
// ── CTA ───────────────────────────────────────────────────
|
||||||
|
wrap.appendChild(el(`
|
||||||
|
<div style="padding:16px 16px 0;">
|
||||||
|
<button class="btn-primary" id="rq-submit" style="width:100%;font-size:15px;padding:14px;">
|
||||||
|
Создать заявку
|
||||||
|
</button>
|
||||||
|
<div id="rq-result" style="margin-top:12px;"></div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
root.appendChild(wrap);
|
||||||
|
_bindWrap(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Client block ────────────────────────────────────────── */
|
||||||
|
function _renderClientBlock(container) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (clientMode === "selected") {
|
||||||
|
// Карточка выбранного клиента
|
||||||
|
container.appendChild(el(`
|
||||||
|
<div style="margin-top:14px;">
|
||||||
|
<div class="field-label" style="margin-bottom:6px;">Клиент</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;padding:12px;
|
||||||
|
background:var(--surface);border:1.5px solid var(--accent,#003E7E);
|
||||||
|
border-radius:10px;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:14px;font-weight:700;color:var(--ink);">${escHtml(state.client_name)}</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:1px;">${escHtml(maskPhone(state.client_phone))}</div>
|
||||||
|
</div>
|
||||||
|
<button id="rq-clear-client" style="background:none;border:none;cursor:pointer;
|
||||||
|
font-size:18px;color:var(--muted);padding:4px 8px;">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Адрес замера</span>
|
||||||
|
<input id="rq-address" type="text" value="${escAttr(state.address)}"
|
||||||
|
placeholder="СПб, Просвещения 87, кв. 12">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
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(`
|
||||||
|
<div style="margin-top:14px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||||
|
<span class="field-label">Новый клиент</span>
|
||||||
|
<button id="rq-back-search" style="background:none;border:none;cursor:pointer;
|
||||||
|
font-size:12px;color:var(--accent,#003E7E);padding:0;">← к поиску</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-bottom:8px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">ФИО *</span>
|
||||||
|
<input id="rq-new-name" type="text" value="${escAttr(state.client_name)}"
|
||||||
|
placeholder="Иванов Иван Иванович" autocomplete="name">
|
||||||
|
<span class="field-error" id="rq-err-name"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-bottom:8px;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Телефон *</span>
|
||||||
|
<input id="rq-new-phone" type="tel" value="${escAttr(state.client_phone)}"
|
||||||
|
placeholder="+7 921 555-12-34" autocomplete="tel">
|
||||||
|
<span class="field-error" id="rq-err-phone"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Адрес замера</span>
|
||||||
|
<input id="rq-new-address" type="text" value="${escAttr(state.address)}"
|
||||||
|
placeholder="СПб, Просвещения 87, кв. 12">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
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(`
|
||||||
|
<div style="margin-top:14px;position:relative;">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Клиент</span>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<input id="rq-search" type="text" placeholder="🔍 Найти по имени или телефону..."
|
||||||
|
autocomplete="off" style="width:100%;box-sizing:border-box;padding-right:36px;">
|
||||||
|
<span id="rq-search-spinner" style="display:none;position:absolute;right:10px;top:50%;
|
||||||
|
transform:translateY(-50%);font-size:14px;">⏳</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div id="rq-dropdown" style="display:none;position:absolute;left:0;right:0;
|
||||||
|
background:var(--surface);border:1px solid var(--border);border-radius:10px;
|
||||||
|
box-shadow:0 4px 16px rgba(0,0,0,.12);z-index:100;max-height:220px;overflow-y:auto;
|
||||||
|
margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<div style="padding:10px 14px;cursor:pointer;border-bottom:1px solid var(--border);
|
||||||
|
display:flex;gap:10px;align-items:center;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(c.client_name || "—")}</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);">
|
||||||
|
${escHtml(maskPhone(c.client_phone))}
|
||||||
|
${c.address ? " · " + escHtml(c.address.slice(0, 30)) : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:18px;color:var(--accent,#003E7E);">›</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<div style="padding:10px 14px;cursor:pointer;color:var(--accent,#003E7E);
|
||||||
|
font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:18px;">+</span> Создать нового клиента
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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 = `<option value="">— Замерщиков нет —</option>`;
|
||||||
|
sel.disabled = true;
|
||||||
|
if (hint) hint.textContent = "Выдайте кому-нибудь роль measurer через /grant_role";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.disabled = false;
|
||||||
|
sel.innerHTML =
|
||||||
|
`<option value="">— Не назначать —</option>` +
|
||||||
|
measurers.map(m =>
|
||||||
|
`<option value="${m.tg_id}">${escHtml(m.full_name || "?")}${m.tg_username ? " (@" + m.tg_username + ")" : ""}</option>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderManagerSelect() {
|
||||||
|
const sel = document.getElementById("rq-manager");
|
||||||
|
if (!sel) return;
|
||||||
|
if (!managers.length) {
|
||||||
|
sel.innerHTML = `<option value="">— Оставить себе —</option>`;
|
||||||
|
sel.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.disabled = false;
|
||||||
|
sel.innerHTML =
|
||||||
|
`<option value="">— Оставить себе —</option>` +
|
||||||
|
managers.map(m =>
|
||||||
|
`<option value="${m.tg_id}">${escHtml(m.full_name || "?")}${m.tg_username ? " (@" + m.tg_username + ")" : ""}</option>`
|
||||||
|
).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 = '<span class="spinner-inline"></span> создаём...';
|
||||||
|
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 = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<div>
|
||||||
|
<div class="success-title">Заявка создана</div>
|
||||||
|
<div class="success-sub">
|
||||||
|
#${(data.id || "").slice(0, 6)}
|
||||||
|
${assignedTo ? " · Замерщик уведомлён" : ""}
|
||||||
|
${handedTo ? ` · Передана ${escHtml(handedTo.full_name || "менеджеру")}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-secondary" id="rq-new">Ещё заявка</button>
|
||||||
|
<button class="btn-primary" id="rq-home">На главную</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
|
||||||
|
btn.disabled = false; btn.textContent = "Попробовать снова";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────── */
|
||||||
|
function _headerEl() {
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Новая заявка на замер</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
location.hash = "";
|
||||||
|
if (typeof routeByHash === "function") routeByHash();
|
||||||
|
});
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
return { mount };
|
return { mount };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
<script src="assets/clients.js?v=20260518e"></script>
|
<script src="assets/clients.js?v=20260518e"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260516h"></script>
|
<script src="assets/zamer-picts.js?v=20260516h"></script>
|
||||||
<script src="assets/measurements.js?v=20260518f"></script>
|
<script src="assets/measurements.js?v=20260518f"></script>
|
||||||
<script src="assets/request.js?v=20260518f"></script>
|
<script src="assets/request.js?v=20260518p"></script>
|
||||||
<script src="assets/assembly.js?v=20260518f"></script>
|
<script src="assets/assembly.js?v=20260518f"></script>
|
||||||
<script src="assets/proposals.js?v=20260518f"></script>
|
<script src="assets/proposals.js?v=20260518f"></script>
|
||||||
<script src="assets/me.js?v=20260518h"></script>
|
<script src="assets/me.js?v=20260518h"></script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user