From 44379576f2688356d015f8f4885c62870757fc34 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 19 May 2026 13:11:07 +0300 Subject: [PATCH] feat: date scheduling flow for assembler/measurer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - _assembly_columns: +date_range, +confirm_by, +confirmed_at - _handle_assembly_create: sets confirm_by = now+3h when assigned_to_tg_id provided - /api/assembly_schedule: staff confirms exact datetime → status→scheduled + gcal event create/update + bot notify manager "Лид закреплён 🎯" - /api/measurement_schedule: same for measurers - staff_clients: return date_range/confirm_by/confirmed_at per assembly, preferred_date/preferred_time_of_day per measurement Frontend (staff_clients.js): - Assembly cards: show date_range hint, confirm_by countdown timer - "📞 Подтвердить дату после созвона" button (only when status=created, no scheduled_at) - Measurement cards: show preferred_date from client, confirm button - _openScheduleOverlay: datetime-local picker + note → POST assembly/measurement_schedule → reload client list on success Co-Authored-By: Claude Sonnet 4.6 --- backend-py/app/main.py | 197 +++++++++++++++++++++++++++++++- miniapp/assets/staff_clients.js | 167 +++++++++++++++++++++++++-- 2 files changed, 348 insertions(+), 16 deletions(-) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index c970873..5b7074c 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -155,6 +155,8 @@ async def _dispatch_post(request: Request): "assembler_analytics": _handle_assembler_analytics, "assembler_earnings": _handle_assembler_earnings, "staff_clients": _handle_staff_clients, + "assembly_schedule": _handle_assembly_schedule, + "measurement_schedule": _handle_measurement_schedule, "contract_preview": _handle_contract_preview, "contract_save": _handle_contract_save, "proposal_brief": proposals_mod.handle_brief, @@ -2302,6 +2304,10 @@ def _assembly_columns() -> list[str]: "signature_file", "signed_at", # Google Calendar "gcal_event_id", "gcal_event_url", + # Планирование: менеджер задаёт диапазон, мастер подтверждает конкретное время + "date_range", # текстовая подсказка от менеджера: "20–22 мая, утро" + "confirm_by", # ISO — дедлайн для подтверждения (назначение + 3 ч) + "confirmed_at", # ISO — когда мастер подтвердил время # Прочее "manager_note", "archived_at", @@ -2371,8 +2377,15 @@ def _handle_assembly_create(body: dict[str, Any]) -> dict[str, Any]: scheduled_at = (body.get("scheduled_at") or "").strip() status = "scheduled" if scheduled_at else "created" + assigned_to = (body.get("assigned_to_tg_id") or "").strip() + date_range = (body.get("date_range") or "").strip() + # Дедлайн подтверждения: 3 часа с момента создания (если есть назначенный мастер) + from datetime import timedelta + confirm_by = (datetime.utcnow() + timedelta(hours=3)).isoformat() if assigned_to else "" + fields = { "manager_tg_id": tg_id, + "assigned_to_tg_id": assigned_to, "client_name": client_name, "client_phone": phone_norm or phone_raw, "address": address, @@ -2383,6 +2396,8 @@ def _handle_assembly_create(body: dict[str, Any]) -> dict[str, Any]: "scheduled_at": scheduled_at, "status": status, "manager_note": (body.get("manager_note") or "").strip(), + "date_range": date_range, + "confirm_by": confirm_by, } # Google Calendar — если дата назначена @@ -3157,6 +3172,9 @@ def _handle_staff_clients(body: dict[str, Any]) -> dict[str, Any]: "scope_of_work": row.get("scope_of_work", ""), "signed_by_name": row.get("signed_by_name", ""), "manager_tg_id": row.get("manager_tg_id", ""), + "date_range": row.get("date_range", ""), + "confirm_by": row.get("confirm_by", ""), + "confirmed_at": row.get("confirmed_at", ""), }) except Exception as e: log.warning("staff_clients assemblies error: %s", e) @@ -3191,12 +3209,14 @@ def _handle_staff_clients(body: dict[str, Any]) -> dict[str, Any]: "measurements": [], } clients[ckey]["measurements"].append({ - "id": row.get("id", ""), - "address": row.get("address", ""), - "status": status, - "scheduled_at": row.get("scheduled_at", ""), - "zamer_no": row.get("zamer_no", ""), - "layout": row.get("layout", ""), + "id": row.get("id", ""), + "address": row.get("address", ""), + "status": status, + "scheduled_at": row.get("scheduled_at", ""), + "zamer_no": row.get("zamer_no", ""), + "layout": row.get("layout", ""), + "preferred_date": row.get("preferred_date", ""), + "preferred_time_of_day": row.get("preferred_time_of_day", ""), }) except Exception as e: log.warning("staff_clients measurements error: %s", e) @@ -3226,6 +3246,171 @@ async def api_staff_clients(request: Request): return _handle_staff_clients(body) +def _handle_assembly_schedule(body: dict[str, Any]) -> dict[str, Any]: + """Мастер подтверждает конкретную дату/время сборки после созвона с клиентом. + body: {initData, assembly_id, scheduled_at: ISO, note?} + После подтверждения → уведомление менеджеру.""" + 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: + return {"error": "user_not_found"} + if not (sheets.is_master(user) or sheets.has_role(user, "assembler") or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + assembly_id = str(body.get("assembly_id") or "").strip() + scheduled_at = str(body.get("scheduled_at") or "").strip() + note = str(body.get("note") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + if not scheduled_at: + return {"error": "missing_scheduled_at"} + + _ensure_assemblies_sheet() + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + # Только назначенный мастер или менеджер могут подтверждать + is_assigned = str(asm.get("assigned_to_tg_id", "")) == str(tg_id) + is_mgr = sheets.has_role(user, "manager") and str(asm.get("manager_tg_id", "")) == str(tg_id) + if not is_assigned and not is_mgr: + return {"error": "not_assigned"} + + now_iso = datetime.utcnow().isoformat() + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "scheduled_at", scheduled_at) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "confirmed_at", now_iso) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "scheduled") + if note: + existing_note = asm.get("manager_note", "") + new_note = f"{existing_note}\n[Подтверждение {now_iso[:10]}]: {note}".strip() + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "manager_note", new_note) + + # Google Calendar — обновляем/создаём событие + try: + from . import gcalendar + ev_id = asm.get("gcal_event_id", "") + client_name = asm.get("client_name", "") + address = asm.get("address", "") + scope = asm.get("scope_of_work", "") + phone = asm.get("client_phone", "") + staff_name = user.get("full_name") or f"{user.get('first_name','')} {user.get('last_name','')}".strip() or str(tg_id) + if ev_id: + gcalendar.update_event(ev_id, start_iso=scheduled_at) + else: + ev = gcalendar.create_event( + summary=f"🔨 Сборка: {client_name}", + description=f"{scope}\n\nКлиент: {client_name}\nТел: {phone}\nАдрес: {address}\nМастер: {staff_name}", + start_iso=scheduled_at, + duration_min=240, + location=address, + ) + if ev: + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "gcal_event_id", ev.get("id", "")) + sheets.update_cell_by_key("Assemblies", "id", assembly_id, "gcal_event_url", ev.get("html_link", "")) + except Exception as e: + log.warning("assembly_schedule gcal error: %s", e) + + # Уведомление менеджеру + manager_tg_id = asm.get("manager_tg_id", "") + if manager_tg_id and str(manager_tg_id) != str(tg_id): + try: + staff_name = user.get("full_name") or f"{user.get('first_name','')} {user.get('last_name','')}".strip() or str(tg_id) + dt_str = scheduled_at[:16].replace("T", " ") + tg.send_message( + int(manager_tg_id), + f"✅ Дата сборки согласована\n\n" + f"Клиент: {asm.get('client_name','')}\n" + f"Адрес: {asm.get('address','')}\n" + f"Дата: {dt_str}\n" + f"Мастер: {staff_name}\n\n" + f"Лид закреплён 🎯", + ) + except Exception as e: + log.warning("assembly_schedule notify error: %s", e) + + sheets.log_event("assembly_scheduled", tg_id, {"id": assembly_id, "scheduled_at": scheduled_at}) + return {"ok": True, "scheduled_at": scheduled_at} + + +@app.post("/api/assembly_schedule") +async def api_assembly_schedule(request: Request): + body = await _safe_json(request) + return _handle_assembly_schedule(body) + + +def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]: + """Замерщик подтверждает дату замера после созвона с клиентом. + body: {initData, measurement_id, scheduled_at: ISO, note?}""" + 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: + return {"error": "user_not_found"} + if not (sheets.has_role(user, "measurer") or sheets.is_master(user) or sheets.has_role(user, "manager")): + return {"error": "forbidden"} + + meas_id = str(body.get("measurement_id") or "").strip() + scheduled_at = str(body.get("scheduled_at") or "").strip() + note = str(body.get("note") or "").strip() + if not meas_id or not scheduled_at: + return {"error": "missing_fields"} + + meas = sheets.find_row("Measurements", "id", meas_id) + if not meas: + return {"error": "measurement_not_found"} + + is_assigned = str(meas.get("assigned_to_tg_id", "")) == str(tg_id) + is_mgr = sheets.has_role(user, "manager") and str(meas.get("manager_tg_id", "")) == str(tg_id) + if not is_assigned and not is_mgr: + return {"error": "not_assigned"} + + now_iso = datetime.utcnow().isoformat() + sheets.update_cell_by_key("Measurements", "id", meas_id, "scheduled_at", scheduled_at) + sheets.update_cell_by_key("Measurements", "id", meas_id, "status", "scheduled") + + # Уведомление менеджеру + manager_tg_id = meas.get("manager_tg_id", "") + if manager_tg_id and str(manager_tg_id) != str(tg_id): + try: + staff_name = user.get("full_name") or f"{user.get('first_name','')} {user.get('last_name','')}".strip() or str(tg_id) + dt_str = scheduled_at[:16].replace("T", " ") + tg.send_message( + int(manager_tg_id), + f"📐 Дата замера согласована\n\n" + f"Клиент: {meas.get('client_name','')}\n" + f"Адрес: {meas.get('address','')}\n" + f"Дата: {dt_str}\n" + f"Замерщик: {staff_name}\n\n" + f"Лид закреплён 🎯", + ) + except Exception as e: + log.warning("measurement_schedule notify error: %s", e) + + sheets.log_event("measurement_scheduled", tg_id, {"id": meas_id, "scheduled_at": scheduled_at}) + return {"ok": True, "scheduled_at": scheduled_at} + + +@app.post("/api/measurement_schedule") +async def api_measurement_schedule(request: Request): + body = await _safe_json(request) + return _handle_measurement_schedule(body) + + def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]: """Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта. body: {initData, initDataUnsafe, assembly_id} diff --git a/miniapp/assets/staff_clients.js b/miniapp/assets/staff_clients.js index a4239cc..2be690e 100644 --- a/miniapp/assets/staff_clients.js +++ b/miniapp/assets/staff_clients.js @@ -271,28 +271,84 @@ const StaffClients = (function () { screen.appendChild(el(`
🔨 Сборки · ${c.assemblies.length}
`)); c.assemblies.forEach(a => { const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" }; + const needsConfirm = !a.scheduled_at && !a.confirmed_at && a.status === "created"; + const confirmDeadline = a.confirm_by ? new Date(a.confirm_by) : null; + const isOverdue = confirmDeadline && confirmDeadline < new Date(); + const asmCard = el(`
-
-
+ border:1px solid ${needsConfirm && !isOverdue ? "var(--accent)" : "var(--border)"}; + border-radius:12px;"> +
+
${s.icon} ${escHtml(s.text)}
${a.address ? `
${escHtml(a.address)}
` : ""} - ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0,60))}
` : ""} + ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0,80))}
` : ""} + ${a.date_range ? `
📅 ${escHtml(a.date_range)}
` : ""}
${a.scheduled_at ? `
${escHtml(fmtDate(a.scheduled_at))}
` : ""} - ${a.signed_by_name ? `
✅ Подписан
` : ""} + ${a.confirmed_at ? `
✅ Согласовано
` : ""} + ${a.signed_by_name ? `
✍️ Подписан
` : ""}
+ ${needsConfirm ? ` +
+ ${confirmDeadline && !isOverdue ? ` +
+ ⏱ Осталось: — +
+ ` : isOverdue ? `
⚠️ Срок подтверждения истёк
` : ""} + +
+ ` : ""}
`); - asmCard.addEventListener("click", () => { + + // Переход в детальный экран по тапу + asmCard.querySelector(".asm-tap").addEventListener("click", () => { haptic && haptic("impact"); - if (typeof AssemblyDetailScreen !== "undefined") { - location.hash = `#/assembly/${a.id}`; - } + location.hash = `#/assembly/${a.id}`; }); + + // Кнопка подтверждения + const confirmBtn = asmCard.querySelector(".confirm-date-btn"); + if (confirmBtn) { + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); + haptic && haptic("impact"); + _openScheduleOverlay(a.id, "assembly", c.client_name, () => { + // После подтверждения перезагружаем список + mount(container); + }); + }); + } + + // Таймер обратного отсчёта + if (confirmDeadline && !isOverdue) { + const timerEl = asmCard.querySelector(`#timer-${a.id}`); + if (timerEl) { + const tick = () => { + const diff = confirmDeadline - new Date(); + if (diff <= 0) { timerEl.textContent = "⚠️ Срок истёк"; return; } + const h = Math.floor(diff / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + const s2 = Math.floor((diff % 60000) / 1000); + timerEl.textContent = `⏱ Осталось: ${h}ч ${m}м ${s2}с`; + }; + tick(); + const iv = setInterval(tick, 1000); + // Останавливаем таймер при уходе со страницы + const obs = new MutationObserver(() => { + if (!document.contains(timerEl)) { clearInterval(iv); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + } + screen.appendChild(asmCard); }); } @@ -302,19 +358,34 @@ const StaffClients = (function () { screen.appendChild(el(`
📐 Замеры · ${c.measurements.length}
`)); c.measurements.forEach(m => { const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" }; + const needsConfirmM = !m.scheduled_at && m.status !== "done" && m.status !== "cancelled"; const mCard = el(`
+ border:1px solid ${needsConfirmM ? "var(--accent)" : "var(--border)"};border-radius:12px;">
${s.icon} ${escHtml(s.text)}
${m.address ? `
${escHtml(m.address)}
` : ""} ${m.zamer_no ? `
Замер №${escHtml(m.zamer_no)}
` : ""} + ${m.preferred_date ? `
📅 Клиент: ${escHtml(m.preferred_date)}
` : ""}
${m.scheduled_at ? `
${escHtml(fmtDate(m.scheduled_at))}
` : ""}
+ ${needsConfirmM ? ` + + ` : ""}
`); + const measConfirmBtn = mCard.querySelector(".confirm-meas-btn"); + if (measConfirmBtn) { + measConfirmBtn.addEventListener("click", () => { + haptic && haptic("impact"); + _openScheduleOverlay(m.id, "measurement", c.client_name, () => mount(container)); + }); + } screen.appendChild(mCard); }); } @@ -322,5 +393,81 @@ const StaffClients = (function () { screen.appendChild(el(`
`)); } + /* ── Оверлей выбора даты/времени ───────────────────────────── */ + function _openScheduleOverlay(itemId, type, clientName, onSuccess) { + document.getElementById("schedule-overlay")?.remove(); + + // Минимальная дата — сегодня + const todayISO = new Date().toISOString().slice(0, 16); + + const overlay = document.createElement("div"); + overlay.id = "schedule-overlay"; + overlay.style.cssText = ` + position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000; + display:flex;align-items:flex-end;justify-content:center; + `; + overlay.innerHTML = ` +
+
+ ${type === "assembly" ? "📅 Дата сборки" : "📅 Дата замера"} +
+
+ ${escHtml(clientName)} +
+ + + + + +
+ +
+ + +
+
+ `; + document.body.appendChild(overlay); + + overlay.querySelector("#sc-cancel").addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); + + overlay.querySelector("#sc-confirm").addEventListener("click", async () => { + const dt = overlay.querySelector("#sc-datetime").value; + const note = overlay.querySelector("#sc-note").value.trim(); + const errEl = overlay.querySelector("#sc-err"); + if (!dt) { errEl.textContent = "Выберите дату и время"; return; } + + const btn = overlay.querySelector("#sc-confirm"); + btn.disabled = true; + btn.textContent = "Сохраняем…"; + errEl.textContent = ""; + + try { + const path = type === "assembly" ? "assembly_schedule" : "measurement_schedule"; + const idKey = type === "assembly" ? "assembly_id" : "measurement_id"; + const res = await _api(path, { [idKey]: itemId, scheduled_at: dt, note }); + if (res.error) throw new Error(res.error); + haptic && haptic("success"); + overlay.remove(); + if (typeof onSuccess === "function") onSuccess(); + } catch (e) { + btn.disabled = false; + btn.textContent = "✅ Подтвердить"; + errEl.textContent = "Ошибка: " + e.message; + } + }); + } + return { mount }; })();