measurement workflow: приблизительная дата от менеджера

По процедуре пользователя: менеджер при создании заявки может не
знать точной даты. Указывает диапазон, замерщик потом созванивается
с клиентом и фиксирует точную дату.

Менеджер при «Заказать замер» — радио-выбор:
  ○ Конкретная дата       (открывает date + утром/днём/вечером)
  ○ Эта неделя
  ○ Следующая неделя
  ● Согласовать с клиентом (default)
  + поле «Уточнение по времени» (свободный текст)

Замерщик в инбоксе:
  - Если scheduled_at заполнено → 📅 точная дата
  - Иначе → 🕐 приблизительная (эта неделя / след. неделя / 15.05 утром)
  + Note выводится после ·
  - В DM-уведомлении строка «Когда: …» подсказывает что согласовать

Замерщик в карточке заявки:
  - Если нет точной даты — отдельный блок « Когда удобно клиенту»
    с подсказкой «позвоните клиенту и согласуйте точную дату»
  - После назначения через datetime-input → блок исчезает

Backend: 4 новые колонки preferred_type / preferred_date /
preferred_time_of_day / preferred_note, добавлены в schema,
serialize/deserialize в request + detail + inbox.

Cache bust v=20260513u.
This commit is contained in:
wasrusgen 2026-05-13 16:21:09 +03:00
parent e37a5e723f
commit fdce3b3c64
5 changed files with 240 additions and 14 deletions

View File

@ -606,6 +606,12 @@ def _measurement_columns() -> list[str]:
"address", "client_name", "client_phone", "address", "client_name", "client_phone",
# Поля Commit C (структура замера по чек-листу) # Поля Commit C (структура замера по чек-листу)
"zamer_no", "zamer_date", "floor_base", "photos_meta", "zamer_no", "zamer_date", "floor_base", "photos_meta",
# Поля для приблизительной даты от менеджера (Commit C2)
# preferred_type: specific | this_week | next_week | tbd
# preferred_date: ISO date если specific
# preferred_time_of_day: morning | day | evening
# preferred_note: «после звонка», «не раньше вторника», ...
"preferred_type", "preferred_date", "preferred_time_of_day", "preferred_note",
] ]
@ -637,6 +643,7 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "", "assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
"address": "", "client_name": "", "client_phone": "", "address": "", "client_name": "", "client_phone": "",
"zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "", "zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "",
"preferred_type": "", "preferred_date": "", "preferred_time_of_day": "", "preferred_note": "",
} }
base.update(fields) base.update(fields)
return [str(base.get(c, "")) for c in cols] return [str(base.get(c, "")) for c in cols]
@ -1120,6 +1127,16 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
assigned_to = str(body.get("assigned_to_tg_id") or "").strip() assigned_to = str(body.get("assigned_to_tg_id") or "").strip()
notes = (body.get("notes") or "").strip() notes = (body.get("notes") or "").strip()
# Приблизительная дата визита (Commit C2)
preferred_type = (body.get("preferred_type") or "tbd").strip()
preferred_date = (body.get("preferred_date") or "").strip()
preferred_time_of_day = (body.get("preferred_time_of_day") or "").strip()
preferred_note = (body.get("preferred_note") or "").strip()
if preferred_type not in ("specific", "this_week", "next_week", "tbd"):
preferred_type = "tbd"
if preferred_time_of_day not in ("morning", "day", "evening", ""):
preferred_time_of_day = ""
if not client_name or not client_phone: if not client_name or not client_phone:
return {"error": "missing_client_info", "hint": "client_name and client_phone are required"} return {"error": "missing_client_info", "hint": "client_name and client_phone are required"}
@ -1144,19 +1161,27 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
client_name=client_name, client_name=client_name,
client_phone=client_phone, client_phone=client_phone,
notes=notes, notes=notes,
preferred_type=preferred_type,
preferred_date=preferred_date,
preferred_time_of_day=preferred_time_of_day,
preferred_note=preferred_note,
)) ))
# Уведомление назначенному замерщику # Уведомление назначенному замерщику
if assigned_to: if assigned_to:
timing_line = _format_preferred_human(
preferred_type, preferred_date, preferred_time_of_day, preferred_note
)
tg.send_message( tg.send_message(
int(assigned_to), int(assigned_to),
f"📐 <b>Новая заявка на замер</b>\n\n" f"📐 <b>Новая заявка на замер</b>\n\n"
f"Клиент: <b>{client_name}</b>\n" f"Клиент: <b>{client_name}</b>\n"
f"Телефон: <code>{client_phone}</code>\n" f"Телефон: <code>{client_phone}</code>\n"
f"Адрес: {address or ''}\n" f"Адрес: {address or ''}\n"
f"Когда: {timing_line}\n"
f"От менеджера: {user.get('full_name') or tg_id}\n\n" f"От менеджера: {user.get('full_name') or tg_id}\n\n"
f"{notes if notes else ''}\n" f"{notes if notes else ''}\n"
f"Откройте кабинет — назначьте дату." f"Откройте кабинет — согласуйте точную дату с клиентом."
) )
sheets.log_event("measurement_requested", tg_id, { sheets.log_event("measurement_requested", tg_id, {
@ -1206,6 +1231,10 @@ def _handle_measurement_inbox(body: dict[str, Any]) -> dict[str, Any]:
"notes": row.get("notes", ""), "notes": row.get("notes", ""),
"manager_tg_id": row.get("manager_tg_id", ""), "manager_tg_id": row.get("manager_tg_id", ""),
"requested_by_tg_id": row.get("requested_by_tg_id", ""), "requested_by_tg_id": row.get("requested_by_tg_id", ""),
"preferred_type": row.get("preferred_type", ""),
"preferred_date": row.get("preferred_date", ""),
"preferred_time_of_day": row.get("preferred_time_of_day", ""),
"preferred_note": row.get("preferred_note", ""),
}) })
# Назначенная дата → первая; затем requested без даты # Назначенная дата → первая; затем requested без даты
def _sort_key(item): def _sort_key(item):
@ -1308,6 +1337,34 @@ def _format_date_human(iso: str) -> str:
return iso return iso
def _format_preferred_human(p_type: str, p_date: str, p_tod: str, p_note: str) -> str:
"""Приблизительная дата от менеджера в человекочитаемом виде."""
tod_map = {"morning": "утром", "day": "днём", "evening": "вечером"}
if p_type == "specific":
date_part = p_date
if p_date:
try:
from datetime import datetime as _dt
date_part = _dt.strptime(p_date, "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
pass
parts = []
if date_part:
parts.append(date_part)
if p_tod in tod_map:
parts.append(tod_map[p_tod])
s = " ".join(parts) if parts else "конкретная дата"
elif p_type == "this_week":
s = "эта неделя"
elif p_type == "next_week":
s = "следующая неделя"
else:
s = "согласовать с клиентом"
if p_note:
s += f" · {p_note}"
return s
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает один замер целиком — для детальной страницы и печати.""" """Возвращает один замер целиком — для детальной страницы и печати."""
cfg = get_config() cfg = get_config()
@ -1372,6 +1429,11 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"zamer_date": row.get("zamer_date", ""), "zamer_date": row.get("zamer_date", ""),
"floor_base": row.get("floor_base", ""), "floor_base": row.get("floor_base", ""),
"photos_meta": _safe_json(row.get("photos_meta", "")), "photos_meta": _safe_json(row.get("photos_meta", "")),
# Приблизительная дата от менеджера (Commit C2)
"preferred_type": row.get("preferred_type", ""),
"preferred_date": row.get("preferred_date", ""),
"preferred_time_of_day": row.get("preferred_time_of_day", ""),
"preferred_note": row.get("preferred_note", ""),
} }

View File

@ -511,7 +511,13 @@ function renderInboxItem(m) {
scheduled: "📅 назначен", scheduled: "📅 назначен",
in_progress: "🔵 в работе", in_progress: "🔵 в работе",
})[m.status] || m.status; })[m.status] || m.status;
const sched = m.scheduled_at ? formatDateHuman(m.scheduled_at) : "дата не назначена"; // Когда: точная дата если назначена, иначе приблизительная
let whenText;
if (m.scheduled_at) {
whenText = "📅 " + formatDateHuman(m.scheduled_at);
} else {
whenText = "🕐 " + formatPreferredHuman(m);
}
const item = el(` const item = el(`
<button class="lead-item" style="text-align:left;"> <button class="lead-item" style="text-align:left;">
@ -521,7 +527,7 @@ function renderInboxItem(m) {
${escHtml(m.address || "адрес не указан")} ${escHtml(m.address || "адрес не указан")}
</div> </div>
<div class="lead-id" style="font-size:11px; color:var(--muted); margin-top:2px;"> <div class="lead-id" style="font-size:11px; color:var(--muted); margin-top:2px;">
${escHtml(sched)} · ${statusLabel} ${escHtml(whenText)} · ${statusLabel}
</div> </div>
</div> </div>
<div class="lead-arrow">${ICONS.chevron || ""}</div> <div class="lead-arrow">${ICONS.chevron || ""}</div>
@ -534,6 +540,33 @@ function renderInboxItem(m) {
return item; return item;
} }
function formatPreferredHuman(m) {
const todMap = { morning: "утром", day: "днём", evening: "вечером" };
const t = m.preferred_type || "tbd";
const parts = [];
if (t === "specific") {
if (m.preferred_date) {
try {
const d = new Date(m.preferred_date);
parts.push(`${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")}`);
} catch (e) { parts.push(m.preferred_date); }
}
if (m.preferred_time_of_day && todMap[m.preferred_time_of_day]) {
parts.push(todMap[m.preferred_time_of_day]);
}
if (!parts.length) parts.push("конкретная дата");
} else if (t === "this_week") {
parts.push("эта неделя");
} else if (t === "next_week") {
parts.push("следующая неделя");
} else {
parts.push("согласовать с клиентом");
}
let s = parts.join(" ");
if (m.preferred_note) s += " · " + m.preferred_note;
return s;
}
function formatDateHuman(iso) { function formatDateHuman(iso) {
if (!iso) return "—"; if (!iso) return "—";
try { try {
@ -607,6 +640,20 @@ async function renderInboxDetail(measurementId) {
</div> </div>
`)); `));
// Приблизительная дата от менеджера (если точной ещё нет — это подсказка)
if (!m.scheduled_at && (m.preferred_type || m.preferred_note)) {
const prefText = formatPreferredHuman(m);
app.appendChild(el(`
<section class="block preferred-block">
<div class="block-head"> Когда удобно клиенту (от менеджера)</div>
<div style="padding:12px 4px;color:var(--ink);font-size:15px;font-weight:500;">${escHtml(prefText)}</div>
<div style="padding:0 4px 4px;color:var(--muted);font-size:12px;">
Позвоните клиенту и согласуйте точную дату она появится ниже.
</div>
</section>
`));
}
// Заметки от менеджера // Заметки от менеджера
if (m.notes) { if (m.notes) {
app.appendChild(el(` app.appendChild(el(`

View File

@ -2066,6 +2066,49 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ===== Заявка на замер: выбор «когда удобно» ===== */
.preferred-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 6px;
}
.pref-opt {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--card, #fff);
border: 1px solid var(--line-strong, rgba(15, 15, 14, 0.16));
border-radius: 10px;
cursor: pointer;
user-select: none;
transition: background 0.12s, border-color 0.12s;
}
.pref-opt input[type="radio"] {
margin: 0;
accent-color: var(--walnut, #6B4A2B);
}
.pref-opt input[type="radio"]:checked + .pref-label {
font-weight: 600;
color: var(--walnut, #6B4A2B);
}
.pref-opt:has(input:checked) {
background: var(--warm, rgba(107, 74, 43, 0.08));
border-color: var(--walnut, #6B4A2B);
}
.pref-label {
font-size: 13px;
color: var(--ink, #1F1A14);
flex: 1;
}
/* Блок «когда удобно» в карточке замерщика */
.preferred-block {
background: var(--warm, rgba(107, 74, 43, 0.08));
border-left: 3px solid var(--walnut, #6B4A2B);
}
/* ===== Замер: фото с тегами ===== */ /* ===== Замер: фото с тегами ===== */
.podbor-header .podbor-help { .podbor-header .podbor-help {
background: transparent; background: transparent;

View File

@ -10,6 +10,11 @@ const MeasurementRequest = (function () {
address: "", address: "",
assigned_to_tg_id: "", assigned_to_tg_id: "",
notes: "", notes: "",
// Приблизительная дата визита
preferred_type: "tbd", // specific | this_week | next_week | tbd
preferred_date: "",
preferred_time_of_day: "", // morning | day | evening | ""
preferred_note: "",
}; };
let measurers = []; let measurers = [];
@ -18,7 +23,10 @@ const MeasurementRequest = (function () {
document.body.classList.remove("has-bottom-nav"); document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav"); const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove(); if (oldNav) oldNav.remove();
state = { client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", notes: "" }; state = {
client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", notes: "",
preferred_type: "tbd", preferred_date: "", preferred_time_of_day: "", preferred_note: "",
};
render(); render();
loadMeasurers(); loadMeasurers();
} }
@ -67,6 +75,49 @@ const MeasurementRequest = (function () {
</label> </label>
</div> </div>
<div class="section-head" style="margin-top:18px;"><span class="label"> Когда удобно клиенту</span></div>
<div class="preferred-options">
<label class="pref-opt">
<input type="radio" name="prefType" value="specific" data-pref="type">
<span class="pref-label">Конкретная дата</span>
</label>
<label class="pref-opt">
<input type="radio" name="prefType" value="this_week" data-pref="type">
<span class="pref-label">Эта неделя</span>
</label>
<label class="pref-opt">
<input type="radio" name="prefType" value="next_week" data-pref="type">
<span class="pref-label">Следующая неделя</span>
</label>
<label class="pref-opt">
<input type="radio" name="prefType" value="tbd" data-pref="type" checked>
<span class="pref-label">Согласовать с клиентом</span>
</label>
</div>
<div class="form-row two-col" id="prefSpecificBox" style="display:none;">
<label class="field">
<span class="field-label">Дата</span>
<input type="date" data-pref="date">
</label>
<label class="field">
<span class="field-label">Время дня</span>
<select data-pref="time_of_day">
<option value="">не важно</option>
<option value="morning">утром</option>
<option value="day">днём</option>
<option value="evening">вечером</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Уточнение по времени</span>
<input type="text" data-pref="note" placeholder="например: после звонка, не раньше вторника">
</label>
</div>
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">Заметки для замерщика</span> <span class="field-label">Заметки для замерщика</span>
@ -96,6 +147,23 @@ const MeasurementRequest = (function () {
state[e.target.dataset.bind] = e.target.value; state[e.target.dataset.bind] = e.target.value;
}); });
}); });
// Радио-кнопки + поля приблизительной даты
node.querySelectorAll("[data-pref]").forEach(inp => {
const key = inp.dataset.pref;
const mapKey = "preferred_" + key;
inp.addEventListener("change", e => {
const val = e.target.type === "radio" ? e.target.value : e.target.value;
state[mapKey] = val;
if (key === "type") togglePrefSpecific(node);
});
});
togglePrefSpecific(node);
}
function togglePrefSpecific(node) {
const box = node.querySelector("#prefSpecificBox");
if (!box) return;
box.style.display = state.preferred_type === "specific" ? "" : "none";
} }
async function loadMeasurers() { async function loadMeasurers() {
@ -151,11 +219,17 @@ const MeasurementRequest = (function () {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
client_name: name, client_name: name,
client_phone: phone, client_phone: phone,
address: state.address || "", address: state.address || "",
assigned_to_tg_id: state.assigned_to_tg_id || "", assigned_to_tg_id: state.assigned_to_tg_id || "",
notes: state.notes || "", notes: state.notes || "",
// Приблизительная дата визита
preferred_type: state.preferred_type || "tbd",
preferred_date: state.preferred_date || "",
preferred_time_of_day: state.preferred_time_of_day || "",
preferred_note: state.preferred_note || "",
}), }),
}); });
const data = await res.json(); const data = await res.json();

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap"> <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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513t"> <link rel="stylesheet" href="assets/styles.css?v=20260513u">
<link rel="stylesheet" href="assets/podbor.css?v=20260513t"> <link rel="stylesheet" href="assets/podbor.css?v=20260513u">
</head> </head>
<body> <body>
<!-- Splash — за пределами #app, render-функции его не смывают --> <!-- Splash — за пределами #app, render-функции его не смывают -->
@ -31,13 +31,13 @@
<div class="loader-tagline">Сделано с душой!</div> <div class="loader-tagline">Сделано с душой!</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260513t"></script> <script src="assets/icons.js?v=20260513u"></script>
<script src="assets/podbor.config.js?v=20260513t"></script> <script src="assets/podbor.config.js?v=20260513u"></script>
<script src="assets/podbor.picts.js?v=20260513t"></script> <script src="assets/podbor.picts.js?v=20260513u"></script>
<script src="assets/podbor.js?v=20260513t"></script> <script src="assets/podbor.js?v=20260513u"></script>
<script src="assets/clients.js?v=20260513t"></script> <script src="assets/clients.js?v=20260513u"></script>
<script src="assets/measurements.js?v=20260513t"></script> <script src="assets/measurements.js?v=20260513u"></script>
<script src="assets/request.js?v=20260513t"></script> <script src="assets/request.js?v=20260513u"></script>
<script src="assets/app.js?v=20260513t"></script> <script src="assets/app.js?v=20260513u"></script>
</body> </body>
</html> </html>