mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 14:04:48 +00:00
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:
parent
e8b9c68c5c
commit
44379576f2
@ -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"✅ <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]:
|
||||
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
|
||||
body: {initData, initDataUnsafe, assembly_id}
|
||||
|
||||
@ -271,28 +271,84 @@ const StaffClients = (function () {
|
||||
screen.appendChild(el(`<div class="section-head"><span class="label">🔨 Сборки · ${c.assemblies.length}</span></div>`));
|
||||
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(`
|
||||
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:12px;cursor:pointer;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
||||
<div>
|
||||
border:1px solid ${needsConfirm && !isOverdue ? "var(--accent)" : "var(--border)"};
|
||||
border-radius:12px;">
|
||||
<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>
|
||||
${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 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.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>
|
||||
${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>
|
||||
`);
|
||||
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(`<div class="section-head" style="margin-top:16px;"><span class="label">📐 Замеры · ${c.measurements.length}</span></div>`));
|
||||
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(`
|
||||
<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>
|
||||
<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.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>
|
||||
${m.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(m.scheduled_at))}</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>
|
||||
`);
|
||||
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(`<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 };
|
||||
})();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user