feat: date scheduling flow for assembler/measurer

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 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-19 13:11:07 +03:00
parent e8b9c68c5c
commit 44379576f2
2 changed files with 348 additions and 16 deletions

View File

@ -155,6 +155,8 @@ async def _dispatch_post(request: Request):
"assembler_analytics": _handle_assembler_analytics, "assembler_analytics": _handle_assembler_analytics,
"assembler_earnings": _handle_assembler_earnings, "assembler_earnings": _handle_assembler_earnings,
"staff_clients": _handle_staff_clients, "staff_clients": _handle_staff_clients,
"assembly_schedule": _handle_assembly_schedule,
"measurement_schedule": _handle_measurement_schedule,
"contract_preview": _handle_contract_preview, "contract_preview": _handle_contract_preview,
"contract_save": _handle_contract_save, "contract_save": _handle_contract_save,
"proposal_brief": proposals_mod.handle_brief, "proposal_brief": proposals_mod.handle_brief,
@ -2302,6 +2304,10 @@ def _assembly_columns() -> list[str]:
"signature_file", "signed_at", "signature_file", "signed_at",
# Google Calendar # Google Calendar
"gcal_event_id", "gcal_event_url", "gcal_event_id", "gcal_event_url",
# Планирование: менеджер задаёт диапазон, мастер подтверждает конкретное время
"date_range", # текстовая подсказка от менеджера: "2022 мая, утро"
"confirm_by", # ISO — дедлайн для подтверждения (назначение + 3 ч)
"confirmed_at", # ISO — когда мастер подтвердил время
# Прочее # Прочее
"manager_note", "manager_note",
"archived_at", "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() scheduled_at = (body.get("scheduled_at") or "").strip()
status = "scheduled" if scheduled_at else "created" 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 = { fields = {
"manager_tg_id": tg_id, "manager_tg_id": tg_id,
"assigned_to_tg_id": assigned_to,
"client_name": client_name, "client_name": client_name,
"client_phone": phone_norm or phone_raw, "client_phone": phone_norm or phone_raw,
"address": address, "address": address,
@ -2383,6 +2396,8 @@ def _handle_assembly_create(body: dict[str, Any]) -> dict[str, Any]:
"scheduled_at": scheduled_at, "scheduled_at": scheduled_at,
"status": status, "status": status,
"manager_note": (body.get("manager_note") or "").strip(), "manager_note": (body.get("manager_note") or "").strip(),
"date_range": date_range,
"confirm_by": confirm_by,
} }
# Google Calendar — если дата назначена # 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", ""), "scope_of_work": row.get("scope_of_work", ""),
"signed_by_name": row.get("signed_by_name", ""), "signed_by_name": row.get("signed_by_name", ""),
"manager_tg_id": row.get("manager_tg_id", ""), "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: except Exception as e:
log.warning("staff_clients assemblies error: %s", 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": [], "measurements": [],
} }
clients[ckey]["measurements"].append({ clients[ckey]["measurements"].append({
"id": row.get("id", ""), "id": row.get("id", ""),
"address": row.get("address", ""), "address": row.get("address", ""),
"status": status, "status": status,
"scheduled_at": row.get("scheduled_at", ""), "scheduled_at": row.get("scheduled_at", ""),
"zamer_no": row.get("zamer_no", ""), "zamer_no": row.get("zamer_no", ""),
"layout": row.get("layout", ""), "layout": row.get("layout", ""),
"preferred_date": row.get("preferred_date", ""),
"preferred_time_of_day": row.get("preferred_time_of_day", ""),
}) })
except Exception as e: except Exception as e:
log.warning("staff_clients measurements error: %s", 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) 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"✅ <b>Дата сборки согласована</b>\n\n"
f"Клиент: <b>{asm.get('client_name','')}</b>\n"
f"Адрес: {asm.get('address','')}\n"
f"Дата: <b>{dt_str}</b>\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"📐 <b>Дата замера согласована</b>\n\n"
f"Клиент: <b>{meas.get('client_name','')}</b>\n"
f"Адрес: {meas.get('address','')}\n"
f"Дата: <b>{dt_str}</b>\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]: def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта. """Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
body: {initData, initDataUnsafe, assembly_id} body: {initData, initDataUnsafe, assembly_id}

View File

@ -271,28 +271,84 @@ const StaffClients = (function () {
screen.appendChild(el(`<div class="section-head"><span class="label">🔨 Сборки · ${c.assemblies.length}</span></div>`)); screen.appendChild(el(`<div class="section-head"><span class="label">🔨 Сборки · ${c.assemblies.length}</span></div>`));
c.assemblies.forEach(a => { c.assemblies.forEach(a => {
const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" }; 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(` const asmCard = el(`
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface); <div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;cursor:pointer;"> border:1px solid ${needsConfirm && !isOverdue ? "var(--accent)" : "var(--border)"};
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;"> border-radius:12px;">
<div> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;cursor:pointer;" class="asm-tap">
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div> <div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div>
${a.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(a.address)}</div>` : ""} ${a.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(a.address)}</div>` : ""}
${a.scope_of_work ? `<div style="font-size:11px;color:var(--muted);margin-top:2px;">${escHtml(a.scope_of_work.slice(0,60))}</div>` : ""} ${a.scope_of_work ? `<div style="font-size:11px;color:var(--muted);margin-top:2px;">${escHtml(a.scope_of_work.slice(0,80))}</div>` : ""}
${a.date_range ? `<div style="font-size:11px;margin-top:4px;color:var(--accent);">📅 ${escHtml(a.date_range)}</div>` : ""}
</div> </div>
<div style="text-align:right;flex-shrink:0;"> <div style="text-align:right;flex-shrink:0;">
${a.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(a.scheduled_at))}</div>` : ""} ${a.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(a.scheduled_at))}</div>` : ""}
${a.signed_by_name ? `<div style="font-size:10px;color:#27AE60;margin-top:2px;">✅ Подписан</div>` : ""} ${a.confirmed_at ? `<div style="font-size:10px;color:#27AE60;margin-top:2px;">✅ Согласовано</div>` : ""}
${a.signed_by_name ? `<div style="font-size:10px;color:#27AE60;margin-top:2px;">✍️ Подписан</div>` : ""}
</div> </div>
</div> </div>
${needsConfirm ? `
<div style="margin-top:10px;">
${confirmDeadline && !isOverdue ? `
<div id="timer-${a.id}" style="font-size:11px;color:${isOverdue ? '#C0392B':'#F39C12'};margin-bottom:6px;">
Осталось:
</div>
` : isOverdue ? `<div style="font-size:11px;color:#C0392B;margin-bottom:6px;"> Срок подтверждения истёк</div>` : ""}
<button class="btn-primary confirm-date-btn" data-id="${escHtml(a.id)}"
style="width:100%;padding:10px;font-size:13px;">
📞 Подтвердить дату после созвона
</button>
</div>
` : ""}
</div> </div>
`); `);
asmCard.addEventListener("click", () => {
// Переход в детальный экран по тапу
asmCard.querySelector(".asm-tap").addEventListener("click", () => {
haptic && haptic("impact"); 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); screen.appendChild(asmCard);
}); });
} }
@ -302,19 +358,34 @@ const StaffClients = (function () {
screen.appendChild(el(`<div class="section-head" style="margin-top:16px;"><span class="label">📐 Замеры · ${c.measurements.length}</span></div>`)); screen.appendChild(el(`<div class="section-head" style="margin-top:16px;"><span class="label">📐 Замеры · ${c.measurements.length}</span></div>`));
c.measurements.forEach(m => { c.measurements.forEach(m => {
const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" }; 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(` const mCard = el(`
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface); <div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;"> border:1px solid ${needsConfirmM ? "var(--accent)" : "var(--border)"};border-radius:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;"> <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
<div> <div>
<div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div> <div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div>
${m.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(m.address)}</div>` : ""} ${m.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(m.address)}</div>` : ""}
${m.zamer_no ? `<div style="font-size:11px;color:var(--muted);">Замер №${escHtml(m.zamer_no)}</div>` : ""} ${m.zamer_no ? `<div style="font-size:11px;color:var(--muted);">Замер №${escHtml(m.zamer_no)}</div>` : ""}
${m.preferred_date ? `<div style="font-size:11px;color:var(--accent);">📅 Клиент: ${escHtml(m.preferred_date)}</div>` : ""}
</div> </div>
${m.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(m.scheduled_at))}</div>` : ""} ${m.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(m.scheduled_at))}</div>` : ""}
</div> </div>
${needsConfirmM ? `
<button class="btn-primary confirm-meas-btn" data-id="${escHtml(m.id)}"
style="width:100%;padding:10px;font-size:13px;margin-top:10px;">
📞 Подтвердить дату замера
</button>
` : ""}
</div> </div>
`); `);
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); screen.appendChild(mCard);
}); });
} }
@ -322,5 +393,81 @@ const StaffClients = (function () {
screen.appendChild(el(`<div style="height:32px;"></div>`)); screen.appendChild(el(`<div style="height:32px;"></div>`));
} }
/* ── Оверлей выбора даты/времени ───────────────────────────── */
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 = `
<div style="width:100%;max-width:480px;background:var(--bg);border-radius:20px 20px 0 0;padding:20px 16px 32px;">
<div style="text-align:center;font-weight:700;font-size:16px;margin-bottom:4px;">
${type === "assembly" ? "📅 Дата сборки" : "📅 Дата замера"}
</div>
<div style="text-align:center;font-size:12px;color:var(--muted);margin-bottom:16px;">
${escHtml(clientName)}
</div>
<label class="field">
<span class="field-label">Дата и время</span>
<input id="sc-datetime" type="datetime-local" min="${todayISO}"
style="width:100%;padding:12px;border:1px solid var(--border);border-radius:10px;
background:var(--surface);color:var(--ink);font-size:15px;">
</label>
<label class="field" style="margin-top:10px;">
<span class="field-label">Заметка (необязательно)</span>
<input id="sc-note" type="text" placeholder="напр. парковка у дома..."
style="width:100%;padding:10px;border:1px solid var(--border);border-radius:10px;
background:var(--surface);color:var(--ink);font-size:14px;">
</label>
<div id="sc-err" style="color:#C0392B;font-size:12px;margin-top:8px;min-height:16px;"></div>
<div style="display:flex;gap:10px;margin-top:14px;">
<button id="sc-cancel" class="btn-secondary" style="flex:1;padding:12px;">Отмена</button>
<button id="sc-confirm" class="btn-primary" style="flex:2;padding:12px;"> Подтвердить</button>
</div>
</div>
`;
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 }; return { mount };
})(); })();