workflow B: заявка на замер от менеджера → инбокс замерщика → завершение

End-to-end поток:

1. Менеджер на главной тапает «Заказать замер» → #/request → форма:
   ФИО · телефон · адрес · dropdown «Кому назначить» · заметки.
   Submit → POST /api/measurement_request → строка в Measurements
   со status=requested + assigned_to_tg_id. Бот шлёт DM замерщику.

2. Замерщик открывает кабинет (?role=staff) → видит inbox с заявкой.
   Тап → #/inbox/<id> → карточка с реквизитами + поле datetime-local.
   Сохранить дату → POST /api/measurement_schedule → status=scheduled.
   Бот уведомляет менеджера.

3. В нужный день замерщик тапает «📐 Сделать замер сейчас» →
   wizard открывается в update-mode (#/measure?id=<id>), pre-fill
   client_name/phone из заявки, пропускает шаг «Клиент». После submit
   → backend обновляет ту же строку (status=completed) + DM менеджеру.

Backend changes:
- Расширена схема Measurements: assigned_to_tg_id, requested_by_tg_id,
  scheduled_at, address, client_name, client_phone (отдельные колонки).
  ensure_measurements_sheet() автоматически дополняет колонки.
- _handle_measurement переписан под 2 режима (create/update).
- 3 новые ручки: /api/measurement_request, /api/measurement_inbox,
  /api/measurement_schedule. Все с правильной проверкой ролей.
- Telegram-уведомления на каждом переходе статуса.

MiniApp:
- Новый модуль request.js — wizard заявки с dropdown замерщиков
  (грузится из /api/staff_list?role=measurer).
- renderStaff теперь грузит реальный инбокс из /api/measurement_inbox.
- renderInboxDetail — карточка заявки с datetime-picker.
- В quick-actions менеджера: «Заказать замер» (primary) +
  «Замер сейчас» (legacy direct fill).
- measurements.js поддерживает update-mode через ?id=.

Cache bust v=20260513g.
This commit is contained in:
wasrusgen 2026-05-12 20:00:16 +03:00
parent d859e9791c
commit 67034e011a
5 changed files with 890 additions and 52 deletions

View File

@ -107,6 +107,9 @@ async def _dispatch_post(request: Request):
"lead": _handle_lead,
"grant_role": _handle_grant_role,
"staff_list": _handle_staff_list,
"measurement_request": _handle_measurement_request,
"measurement_inbox": _handle_measurement_inbox,
"measurement_schedule": _handle_measurement_schedule,
"ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(),
@ -179,6 +182,24 @@ async def api_measurement_detail(request: Request):
return _handle_measurement_detail(body)
@app.post("/api/measurement_request")
async def api_measurement_request(request: Request):
body = await _safe_json(request)
return _handle_measurement_request(body)
@app.post("/api/measurement_inbox")
async def api_measurement_inbox(request: Request):
body = await _safe_json(request)
return _handle_measurement_inbox(body)
@app.post("/api/measurement_schedule")
async def api_measurement_schedule(request: Request):
body = await _safe_json(request)
return _handle_measurement_schedule(body)
@app.post("/api/grant_role")
async def api_grant_role(request: Request):
"""Админ выдаёт роль другому пользователю.
@ -518,7 +539,55 @@ def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str
return None
def _measurement_columns() -> list[str]:
"""Гарантирует что у листа Measurements есть все нужные колонки (расширенный набор)."""
return [
"id", "ts", "client_tg_id", "manager_tg_id", "filled_by",
"layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches",
"photos", "notes", "status",
# Новые поля (Commit B)
"assigned_to_tg_id", "requested_by_tg_id", "scheduled_at",
"address", "client_name", "client_phone",
]
def _ensure_measurements_sheet() -> None:
"""Один раз догоняет схему Measurements — добавляет недостающие колонки."""
try:
ws = sheets.sheet("Measurements")
existing = ws.row_values(1)
except Exception:
sheets.ensure_sheet("Measurements", _measurement_columns())
return
want = _measurement_columns()
missing = [c for c in want if c not in existing]
if missing:
new_headers = existing + missing
ws.update("A1", [new_headers])
log.info("Measurements: дополнили колонки: %s", missing)
def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
"""Собирает строку в нужном порядке колонок (для append_row)."""
cols = _measurement_columns()
base = {
"id": measurement_id, "ts": ts,
"client_tg_id": "", "manager_tg_id": "", "filled_by": "",
"layout": "", "area_m2": "", "ceiling_mm": "",
"walls": "{}", "openings": "{}", "infra": "{}", "niches": "{}",
"photos": "", "notes": "", "status": "submitted",
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
"address": "", "client_name": "", "client_phone": "",
}
base.update(fields)
return [str(base.get(c, "")) for c in cols]
def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
"""Полная сдача замера (когда форма заполнена). Поддерживает 2 режима:
1. Создать новый замер с данными (старый MVP-режим сам менеджер сделал замер)
2. Обновить существующий request статус completed (для замерщика после посещения)
"""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
@ -528,22 +597,55 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
if not user:
return {"error": "user_not_found"}
_ensure_measurements_sheet()
m = body.get("measurement") or {}
measurement_id = _short_id()
filled_by = "manager_for_client" if user.get("role") == "manager" else "client_self"
existing_id = (m.get("measurement_id") or body.get("measurement_id") or "").strip()
update_mode = bool(existing_id)
is_manager = sheets.has_role(user, "manager")
is_measurer = sheets.has_role(user, "measurer")
client_tg_id = m.get("client_tg_id") if user.get("role") == "manager" else tg_id
manager_tg_id = tg_id if user.get("role") == "manager" else (
sheets.find_row("Clients", "tg_id", tg_id) or {}
).get("manager_tg_id", "")
measurement_id = existing_id or _short_id()
if is_manager and not is_measurer:
filled_by = "manager_for_client"
elif is_measurer:
filled_by = "measurer"
else:
filled_by = "client_self"
# Прикрепляем имя/телефон клиента к notes если client_tg_id нет (новый клиент)
# При update-mode загружаем существующую заявку и проверяем права
existing_row = None
if update_mode:
existing_row = sheets.find_row("Measurements", "id", measurement_id)
if not existing_row:
return {"error": "measurement_not_found"}
# Только назначенный замерщик или менеджер-владелец могут завершить
if str(existing_row.get("assigned_to_tg_id")) != str(tg_id) and \
str(existing_row.get("manager_tg_id")) != str(tg_id):
return {"error": "forbidden"}
client_tg_id = (existing_row or {}).get("client_tg_id") or \
(m.get("client_tg_id") if is_manager else tg_id) or ""
manager_tg_id = (existing_row or {}).get("manager_tg_id") or (
tg_id if is_manager else
(sheets.find_row("Clients", "tg_id", tg_id) or {}).get("manager_tg_id", "")
)
client_name = m.get("client_name") or (existing_row or {}).get("client_name", "")
client_phone = m.get("client_phone") or (existing_row or {}).get("client_phone", "")
address = m.get("address") or (existing_row or {}).get("address", "")
assigned_to = (existing_row or {}).get("assigned_to_tg_id", "")
requested_by = (existing_row or {}).get("requested_by_tg_id", manager_tg_id or "")
scheduled_at = (existing_row or {}).get("scheduled_at", "")
# Прикрепляем имя/телефон/адрес к notes (для совместимости со старым кодом)
notes_full = m.get("notes", "")
extras = []
if m.get("client_name"):
extras.append(f"Клиент: {m['client_name']}")
if m.get("client_phone"):
extras.append(f"Тел: {m['client_phone']}")
if client_name:
extras.append(f"Клиент: {client_name}")
if client_phone:
extras.append(f"Тел: {client_phone}")
if address:
extras.append(f"Адрес: {address}")
if extras:
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
@ -560,23 +662,71 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
# уже готовое имя/URL — пропускаем как есть
saved_photos.append(p)
sheets.append_row("Measurements", [
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
filled_by,
m.get("layout", ""), m.get("area_m2", ""), m.get("ceiling_mm", ""),
json.dumps(m.get("walls") or {}, ensure_ascii=False),
json.dumps(m.get("openings") or {}, ensure_ascii=False),
json.dumps(m.get("infra") or {}, ensure_ascii=False),
json.dumps(m.get("niches") or {}, ensure_ascii=False),
",".join(saved_photos),
notes_full,
"submitted",
])
status_new = "completed"
walls_json = json.dumps(m.get("walls") or {}, ensure_ascii=False)
openings_json = json.dumps(m.get("openings") or {}, ensure_ascii=False)
infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False)
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
photos_str = ",".join(saved_photos)
if update_mode:
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
updates = {
"filled_by": filled_by,
"layout": m.get("layout", ""),
"area_m2": m.get("area_m2", ""),
"ceiling_mm": m.get("ceiling_mm", ""),
"walls": walls_json,
"openings": openings_json,
"infra": infra_json,
"niches": niches_json,
"photos": photos_str,
"notes": notes_full,
"status": status_new,
}
for col, val in updates.items():
sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val)
else:
sheets.append_row("Measurements", _row_for_measurement(
measurement_id, _now_iso(),
client_tg_id=client_tg_id or "",
manager_tg_id=manager_tg_id or "",
filled_by=filled_by,
layout=m.get("layout", ""),
area_m2=m.get("area_m2", ""),
ceiling_mm=m.get("ceiling_mm", ""),
walls=walls_json,
openings=openings_json,
infra=infra_json,
niches=niches_json,
photos=photos_str,
notes=notes_full,
status=status_new,
assigned_to_tg_id=assigned_to,
requested_by_tg_id=requested_by,
scheduled_at=scheduled_at,
address=address,
client_name=client_name,
client_phone=client_phone,
))
if client_tg_id:
sheets.update_cell_by_key("Clients", "tg_id", client_tg_id, "last_measurement_id", measurement_id)
if filled_by == "client_self" and manager_tg_id:
# Уведомления
if update_mode and existing_row:
# Замерщик завершил — пишем менеджеру который создавал заявку
notify_to = requested_by or manager_tg_id
if notify_to and str(notify_to) != str(tg_id):
tg.send_message(
notify_to,
f"✅ <b>Замер выполнен</b>\n"
f"Клиент: <b>{client_name or ''}</b>\n"
f"Замерщик: {user.get('full_name') or tg_id}\n"
f"Фото: {len(saved_photos)} шт · площадь {m.get('area_m2') or ''} м²\n\n"
f"Откройте кабинет — можно запускать подбор техники."
)
elif filled_by == "client_self" and manager_tg_id:
tg.send_message(
manager_tg_id,
f"📐 Новый замер от клиента <b>{user.get('full_name') or tg_id}</b>.\n"
@ -584,8 +734,10 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
f"Открыть в кабинете для просмотра."
)
sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by})
return {"ok": True, "id": measurement_id, "photos": saved_photos}
sheets.log_event("measurement_submitted", tg_id, {
"id": measurement_id, "filled_by": filled_by, "update_mode": update_mode,
})
return {"ok": True, "id": measurement_id, "photos": saved_photos, "status": status_new}
def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
@ -870,6 +1022,177 @@ 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_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
"""Менеджер создаёт ЗАЯВКУ на замер (без замеров — пустая заготовка).
body: {initData, client_name, client_phone, address, assigned_to_tg_id?, notes?}"""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
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"}
_ensure_measurements_sheet()
client_name = (body.get("client_name") or "").strip()
client_phone = (body.get("client_phone") or "").strip()
address = (body.get("address") or "").strip()
assigned_to = str(body.get("assigned_to_tg_id") or "").strip()
notes = (body.get("notes") or "").strip()
if not client_name or not client_phone:
return {"error": "missing_client_info", "hint": "client_name and client_phone are required"}
# Если назначен — проверим что у него есть роль measurer
if assigned_to:
try:
assigned_user = sheets.find_user(int(assigned_to))
except (TypeError, ValueError):
assigned_user = None
if not assigned_user or not sheets.has_role(assigned_user, "measurer"):
return {"error": "assigned_not_measurer"}
measurement_id = _short_id()
sheets.append_row("Measurements", _row_for_measurement(
measurement_id, _now_iso(),
manager_tg_id=tg_id,
filled_by="request",
status="requested",
assigned_to_tg_id=assigned_to,
requested_by_tg_id=tg_id,
address=address,
client_name=client_name,
client_phone=client_phone,
notes=notes,
))
# Уведомление назначенному замерщику
if assigned_to:
tg.send_message(
int(assigned_to),
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"{notes if notes else ''}\n"
f"Откройте кабинет — назначьте дату."
)
sheets.log_event("measurement_requested", tg_id, {
"id": measurement_id, "assigned_to": assigned_to, "client": client_name,
})
return {"ok": True, "id": measurement_id, "status": "requested", "assigned_to_tg_id": assigned_to}
def _handle_measurement_inbox(body: dict[str, Any]) -> dict[str, Any]:
"""Замерщик: список назначенных мне заявок (requested/scheduled/in_progress)."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
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, "measurer"):
return {"error": "only_measurer"}
_ensure_measurements_sheet()
try:
ws = sheets.sheet("Measurements")
rows = ws.get_all_values()
except Exception as e:
log.warning("inbox read failed: %s", e)
return {"ok": True, "measurements": []}
if not rows or len(rows) < 2:
return {"ok": True, "measurements": []}
headers = rows[0]
active_statuses = {"requested", "scheduled", "in_progress"}
out: list[dict[str, Any]] = []
for r in rows[1:]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if str(row.get("assigned_to_tg_id", "")) != str(tg_id):
continue
if row.get("status") not in active_statuses:
continue
out.append({
"id": row.get("id"),
"created_at": row.get("ts"),
"status": row.get("status"),
"scheduled_at": row.get("scheduled_at", ""),
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
"address": row.get("address", ""),
"notes": row.get("notes", ""),
"manager_tg_id": row.get("manager_tg_id", ""),
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
})
# Назначенная дата → первая; затем requested без даты
def _sort_key(item):
sched = item.get("scheduled_at") or ""
return (0 if sched else 1, sched, item.get("created_at") or "")
out.sort(key=_sort_key)
return {"ok": True, "count": len(out), "measurements": out}
def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]:
"""Замерщик назначает дату посещения. body: {initData, measurement_id, scheduled_at}"""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
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, "measurer"):
return {"error": "only_measurer"}
measurement_id = (body.get("measurement_id") or "").strip()
scheduled_at = (body.get("scheduled_at") or "").strip()
if not measurement_id or not scheduled_at:
return {"error": "missing_fields"}
row = sheets.find_row("Measurements", "id", measurement_id)
if not row:
return {"error": "measurement_not_found"}
if str(row.get("assigned_to_tg_id")) != str(tg_id):
return {"error": "forbidden"}
sheets.update_cell_by_key("Measurements", "id", measurement_id, "scheduled_at", scheduled_at)
sheets.update_cell_by_key("Measurements", "id", measurement_id, "status", "scheduled")
# Уведомляем менеджера
notify_to = row.get("requested_by_tg_id") or row.get("manager_tg_id")
if notify_to and str(notify_to) != str(tg_id):
try:
tg.send_message(
int(notify_to),
f"📅 <b>Замер назначен</b>\n\n"
f"Клиент: <b>{row.get('client_name') or ''}</b>\n"
f"Дата: {_format_date_human(scheduled_at)}\n"
f"Замерщик: {user.get('full_name') or tg_id}\n"
f"Адрес: {row.get('address') or ''}"
)
except Exception:
pass
sheets.log_event("measurement_scheduled", tg_id, {
"id": measurement_id, "scheduled_at": scheduled_at,
})
return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at}
def _format_date_human(iso: str) -> str:
"""ISO datetime → '15.05.2026 14:00' для уведомлений."""
if not iso:
return ""
try:
d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return d.strftime("%d.%m.%Y %H:%M")
except Exception:
return iso
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает один замер целиком — для детальной страницы и печати."""
cfg = get_config()
@ -922,6 +1245,13 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"photos": photo_files,
"notes": row.get("notes", ""),
"status": row.get("status", ""),
# Новые поля (Commit B)
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
"scheduled_at": row.get("scheduled_at", ""),
"address": row.get("address", ""),
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
}

View File

@ -177,10 +177,10 @@ function renderManagerHome(me) {
// Quick actions
const quickActions = [
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" },
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
{ icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" },
];
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`);
@ -344,7 +344,7 @@ function buildMenu(items) {
}
/* ----------------- Staff (замерщик / сборщик) ----------------- */
function renderStaff(me) {
async function renderStaff(me) {
app.innerHTML = "";
if (me.error === "no_staff_role") {
@ -382,23 +382,53 @@ function renderStaff(me) {
</div>
`));
// Заглушка — реальный инбокс заявок будет в следующем коммите
const inbox = el(`
// Реальный инбокс — загружаем из /api/measurement_inbox
const inboxSection = el(`
<section class="block">
<div class="block-head">📥 Входящие заявки</div>
<div class="empty" style="padding:24px 12px;text-align:center;color:var(--muted);">
Пока пусто менеджеры ещё не назначили вам заявки.<br>
Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}.
</div>
<div class="block-head">📥 Входящие заявки на замер</div>
<div id="inboxList"><div class="loader-inline"><div class="spinner"></div></div></div>
</section>
`);
app.appendChild(inbox);
app.appendChild(inboxSection);
// Если у сотрудника также есть роль measurer — показываем быструю кнопку «Сделать замер»
if (caps.measurer) {
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "" }),
});
const data = await res.json();
const list = document.getElementById("inboxList");
if (!list) return;
if (data.error) {
list.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
} else if (!data.measurements || !data.measurements.length) {
list.innerHTML = `
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
Заявок пока нет. Когда менеджер назначит замер увидите здесь.
</div>
`;
} else {
list.innerHTML = "";
data.measurements.forEach(m => list.appendChild(renderInboxItem(m)));
}
} catch (e) {
const list = document.getElementById("inboxList");
if (list) list.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
}
} else {
document.getElementById("inboxList").innerHTML = `
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
У вас только роль «сборщик» инбокс заявок на сборку появится позже.
</div>
`;
}
// Quick action — заполнить замер без заявки (вне очереди)
if (caps.measurer) {
const quick = el(`
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-primary" id="newMeasure">📐 Сделать новый замер</button>
<button class="btn-secondary" id="newMeasure">📐 Замер без заявки (вручную)</button>
</div>
`);
quick.querySelector("#newMeasure").addEventListener("click", () => {
@ -409,6 +439,195 @@ function renderStaff(me) {
}
}
function renderInboxItem(m) {
const statusLabel = ({
requested: "🟡 ждёт даты",
scheduled: "📅 назначен",
in_progress: "🔵 в работе",
})[m.status] || m.status;
const sched = m.scheduled_at ? formatDateHuman(m.scheduled_at) : "дата не назначена";
const item = el(`
<button class="lead-item" style="text-align:left;">
<div style="flex:1; min-width:0;">
<div class="lead-date" style="font-weight:600; color:var(--ink);">${escHtml(m.client_name || "—")}</div>
<div class="lead-id" style="font-size:12px; color:var(--muted); margin-top:2px;">
${escHtml(m.address || "адрес не указан")}
</div>
<div class="lead-id" style="font-size:11px; color:var(--muted); margin-top:2px;">
${escHtml(sched)} · ${statusLabel}
</div>
</div>
<div class="lead-arrow">${ICONS.chevron || ""}</div>
</button>
`);
item.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/inbox/${m.id}`;
});
return item;
}
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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/* ----------------- Карточка заявки для замерщика ----------------- */
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 class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">Заявка на замер</div>
<div style="width:28px"></div>
</header>
`);
header.querySelector(".podbor-back").addEventListener("click", () => {
location.hash = "";
if (!location.hash) location.reload();
});
app.appendChild(header);
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
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(`<div class="error">Сеть: ${e.message}</div>`));
return;
}
loading.remove();
if (m.error) {
app.appendChild(el(`<div class="error">${m.error}</div>`));
return;
}
// Шапка
app.appendChild(el(`
<div class="measurement-detail-head">
<div class="kicker">Заявка #${(m.id || "").slice(0, 8)}</div>
<h2 class="display-title">${escHtml(m.client_name || "Без имени")}</h2>
<div class="measurement-detail-meta">
<span>📞 ${escHtml(m.client_phone || "—")}</span>
<span>📍 ${escHtml(m.address || "адрес не указан")}</span>
</div>
</div>
`));
// Заметки от менеджера
if (m.notes) {
app.appendChild(el(`
<section class="block">
<div class="block-head">Заметки от менеджера</div>
<div style="padding:12px 4px;color:var(--ink-2);font-size:14px;">${escHtml(m.notes).replace(/\n/g, "<br>")}</div>
</section>
`));
}
// Блок «назначить дату» (если ещё requested) или «изменить дату» (если scheduled)
const isScheduled = m.status === "scheduled";
const schedSection = el(`
<section class="block">
<div class="block-head">${isScheduled ? "Дата замера" : "Назначить дату"}</div>
<div style="padding:6px 0 0;">
<div class="form-row">
<label class="field">
<span class="field-label">Дата и время визита</span>
<input type="datetime-local" id="schedInput" value="${m.scheduled_at ? toDatetimeLocalValue(m.scheduled_at) : ""}">
<span class="field-hint" id="schedHint">${isScheduled ? "Согласовано — можно изменить" : "Согласуйте с клиентом, потом выберите тут"}</span>
<span class="field-error" id="schedError"></span>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-primary" id="saveSched">${isScheduled ? "Изменить дату" : "Назначить"}</button>
</div>
</div>
</section>
`);
app.appendChild(schedSection);
schedSection.querySelector("#saveSched").addEventListener("click", async () => {
const input = schedSection.querySelector("#schedInput");
const errorEl = schedSection.querySelector("#schedError");
errorEl.textContent = "";
const val = input.value;
if (!val) {
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 || "",
measurement_id: measurementId,
scheduled_at: iso,
}),
});
const data = await res.json();
if (data.error) {
errorEl.textContent = "Ошибка: " + data.error;
return;
}
haptic && haptic("success");
tg?.showAlert?.("Дата назначена — менеджер уведомлён.");
renderInboxDetail(measurementId); // перерисовать
} catch (e) {
errorEl.textContent = "Сеть: " + e.message;
}
});
// Кнопка «Сделать замер» (только если назначено или прямо сейчас)
const measureBtn = el(`
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-primary" id="goMeasure">📐 Сделать замер сейчас</button>
</div>
`);
measureBtn.querySelector("#goMeasure").addEventListener("click", () => {
haptic && haptic("impact");
// Передаём measurement_id чтобы wizard работал в update-mode
location.hash = `#/measure?id=${measurementId}`;
});
app.appendChild(measureBtn);
}
function toDatetimeLocalValue(iso) {
// ISO → YYYY-MM-DDTHH:MM для <input type="datetime-local">
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(`
@ -439,12 +658,17 @@ async function init() {
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
window.addEventListener("hashchange", routeByHash);
// ?go=podbor|clients|measure — бот может задать стартовый экран через query,
// ?go=podbor|clients|measure|request — бот может задать стартовый экран через query,
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
const qp = new URLSearchParams(window.location.search);
const goScreen = qp.get("go");
if (goScreen && !location.hash) {
const map = { podbor: "#/podbor", clients: "#/clients", measure: "#/measure" };
const map = {
podbor: "#/podbor",
clients: "#/clients",
measure: "#/measure",
request: "#/request",
};
if (map[goScreen]) {
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
@ -469,6 +693,17 @@ async function init() {
hideSplash();
return;
}
if (location.hash.startsWith("#/request")) {
MeasurementRequest.mount(app);
hideSplash();
return;
}
if (location.hash.startsWith("#/inbox/")) {
const id = location.hash.replace("#/inbox/", "");
renderInboxDetail(id);
hideSplash();
return;
}
if (me.role === "staff") {
renderStaff(me);
} else if (me.role === "manager") {
@ -491,6 +726,10 @@ function routeByHash() {
Clients.mount(app);
} else if (location.hash.startsWith("#/measure")) {
Measurements.mount(app);
} else if (location.hash.startsWith("#/request")) {
MeasurementRequest.mount(app);
} else if (location.hash.startsWith("#/inbox/")) {
renderInboxDetail(location.hash.replace("#/inbox/", ""));
} else {
// Главный экран по роли
const me = window.__zovMe;

View File

@ -21,6 +21,7 @@ const Measurements = (function () {
let state = loadState();
let root = null;
let currentStep = "client";
let measurementId = ""; // если задан — wizard работает в update-mode (закрывает заявку)
function loadState() {
try {
@ -71,9 +72,50 @@ const Measurements = (function () {
if (oldNav) oldNav.remove();
currentStep = "client";
photos = []; // на старте нового замера — чистый список
// Если URL содержит ?id=<measurement_id> или fragment #/measure?id=... — это update-mode:
// wizard закрывает существующую заявку. Подтягиваем данные заявки и сразу прыгаем на layout.
measurementId = "";
const hashMatch = (location.hash.split("?")[1] || "");
const fragQp = new URLSearchParams(hashMatch);
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
if (mid) {
measurementId = mid;
loadRequestAndStart();
return;
}
render();
}
async function loadRequestAndStart() {
// Загружаем существующую заявку и пред-заполняем client_name/phone/address
root.innerHTML = "";
root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }),
});
const data = await res.json();
if (data.error) {
root.innerHTML = `<div class="error">${data.error}</div>`;
return;
}
// Pre-fill state с данными из заявки
state = {
...defaultState(),
client_name: data.client_name || "",
client_phone: data.client_phone || "",
};
saveState();
// Сразу прыгаем на форму (client уже заполнен)
currentStep = "layout";
render();
} catch (e) {
root.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
}
}
function go(step) {
if (!STEPS.includes(step)) return;
currentStep = step;
@ -511,6 +553,8 @@ const Measurements = (function () {
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
client_name: state.client_name,
client_phone: state.client_phone,
// Если задан — backend обновит существующую заявку (update-mode)
measurement_id: measurementId || undefined,
};
try {

224
miniapp/assets/request.js Normal file
View File

@ -0,0 +1,224 @@
/* ============================================================
Заявка на замер менеджер создаёт, замерщику в инбокс
============================================================ */
const MeasurementRequest = (function () {
let root = null;
let state = {
client_name: "",
client_phone: "",
address: "",
assigned_to_tg_id: "",
notes: "",
};
let measurers = [];
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: "", notes: "" };
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" 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" 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" 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="notes" rows="3" placeholder="газ/электро, особые условия доступа, ниши под технику, ..."></textarea>
</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 res = await fetch(`${BACKEND_URL}/api/staff_list`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "", role: "measurer" }),
});
const data = await res.json();
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 res = await fetch(`${BACKEND_URL}/api/measurement_request`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
client_name: name,
client_phone: phone,
address: state.address || "",
assigned_to_tg_id: state.assigned_to_tg_id || "",
notes: state.notes || "",
}),
});
const data = await res.json();
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 = "";
location.reload();
});
} 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 (!location.hash) location.reload();
});
return h;
}
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
return { mount };
})();

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513f">
<link rel="stylesheet" href="assets/podbor.css?v=20260513f">
<link rel="stylesheet" href="assets/styles.css?v=20260513g">
<link rel="stylesheet" href="assets/podbor.css?v=20260513g">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,12 +34,13 @@
</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513f"></script>
<script src="assets/podbor.config.js?v=20260513f"></script>
<script src="assets/podbor.picts.js?v=20260513f"></script>
<script src="assets/podbor.js?v=20260513f"></script>
<script src="assets/clients.js?v=20260513f"></script>
<script src="assets/measurements.js?v=20260513f"></script>
<script src="assets/app.js?v=20260513f"></script>
<script src="assets/icons.js?v=20260513g"></script>
<script src="assets/podbor.config.js?v=20260513g"></script>
<script src="assets/podbor.picts.js?v=20260513g"></script>
<script src="assets/podbor.js?v=20260513g"></script>
<script src="assets/clients.js?v=20260513g"></script>
<script src="assets/measurements.js?v=20260513g"></script>
<script src="assets/request.js?v=20260513g"></script>
<script src="assets/app.js?v=20260513g"></script>
</body>
</html>