flow: упрощённая заявка + 3 чёткие стадии у замерщика

По цепочке менеджер→замерщик→замер:

Менеджер «Заказать замер»:
  - ФИО, телефон, адрес, кому назначить
  - Одно поле «Примечание» (рекомендации по дате + особенности)
    Убраны radio-buttons specific/this_week/next_week — слишком сложно.
    Точную дату всё равно согласует замерщик с клиентом.

Замерщик в карточке заявки — 3 чёткие стадии:

  1. ЕСЛИ статус requested (дата не назначена):
     - Блок «📞 Согласовать дату с клиентом»
     - Подсказка «Позвоните клиенту и зафиксируйте»
     - datetime-local + кнопка «Назначить»

  2. ЕСЛИ статус scheduled (дата уже есть):
     - Блок «📅 Замер назначен» крупно (Newsreader 22pt italic)
     - Кнопка «Изменить дату» — разворачивает скрытую форму
     - ОСНОВНАЯ кнопка «📐 Начать замер» (большая, primary, 16pt)
     - До «Начать замер» чек-листа не видно

Чек-лист (📋 в шапке) теперь живёт ТОЛЬКО в мастере замера
(когда нажали «Начать замер»). До этого момента не отвлекает.

Backend: DM при создании заявки шлёт только примечание
(без расшифровки preferred_type).

Cache bust v=20260513z.
This commit is contained in:
wasrusgen 2026-05-13 18:12:18 +03:00
parent 9e23239f57
commit 366625be66
5 changed files with 147 additions and 168 deletions

View File

@ -1196,18 +1196,15 @@ def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
# Уведомление назначенному замерщику
if assigned_to:
timing_line = _format_preferred_human(
preferred_type, preferred_date, preferred_time_of_day, preferred_note
)
note_line = f"\nПримечание: {preferred_note}" if preferred_note else ""
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"Когда: {timing_line}\n"
f"От менеджера: {user.get('full_name') or tg_id}\n\n"
f"{notes if notes else ''}\n"
f"От менеджера: {user.get('full_name') or tg_id}"
f"{note_line}\n\n"
f"Откройте кабинет — согласуйте точную дату с клиентом."
)

View File

@ -541,6 +541,9 @@ function renderInboxItem(m) {
}
function formatPreferredHuman(m) {
// Теперь приоритет — текст из preferred_note (свободная форма).
// Старые записи с preferred_type/date/time_of_day выводятся как fallback.
if (m.preferred_note) return m.preferred_note;
const todMap = { morning: "утром", day: "днём", evening: "вечером" };
const t = m.preferred_type || "tbd";
const parts = [];
@ -562,9 +565,7 @@ function formatPreferredHuman(m) {
} else {
parts.push("согласовать с клиентом");
}
let s = parts.join(" ");
if (m.preferred_note) s += " · " + m.preferred_note;
return s;
return parts.join(" ");
}
function formatDateHuman(iso) {
@ -640,62 +641,102 @@ async function renderInboxDetail(measurementId) {
</div>
`));
// Приблизительная дата от менеджера (если точной ещё нет — это подсказка)
if (!m.scheduled_at && (m.preferred_type || m.preferred_note)) {
const prefText = formatPreferredHuman(m);
// Примечание от менеджера (рекомендации по дате, особенности доступа)
if (m.preferred_note) {
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>
<div class="block-head">📝 Примечание менеджера</div>
<div style="padding:12px 4px;color:var(--ink);font-size:14.5px;line-height:1.5;">${escHtml(m.preferred_note).replace(/\n/g, "<br>")}</div>
</section>
`));
}
// Заметки от менеджера
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>
`));
}
// Блок логистики — заполняется замерщиком/сборщиком на месте
// Блок логистики (подъезд, GPS, парковка) — заполняется на месте
app.appendChild(renderLogisticsBlock(m));
// Блок «назначить дату» (если ещё 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;">
// Блок даты замера — две версии в зависимости от статуса
const isScheduled = m.status === "scheduled" && m.scheduled_at;
if (isScheduled) {
// Дата назначена — показываем её крупно + кнопка «Изменить»
const dateSection = el(`
<section class="block date-set-block">
<div class="block-head">📅 Замер назначен</div>
<div class="date-set-value">${escHtml(formatDateHuman(m.scheduled_at))}</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="changeDate" type="button">Изменить дату</button>
</div>
<div class="date-set-form" id="changeDateForm" style="display:none;">
<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-label">Новая дата и время</span>
<input type="datetime-local" id="schedInput" value="${toDatetimeLocalValue(m.scheduled_at)}">
<span class="field-error" id="schedError"></span>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-primary" id="saveSched">${isScheduled ? "Изменить дату" : "Назначить"}</button>
<button class="btn-secondary" id="cancelChange" type="button">Отмена</button>
<button class="btn-primary" id="saveSched" type="button">Сохранить</button>
</div>
</div>
</section>
`);
app.appendChild(schedSection);
app.appendChild(dateSection);
dateSection.querySelector("#changeDate").addEventListener("click", () => {
dateSection.querySelector("#changeDateForm").style.display = "";
dateSection.querySelector("#changeDate").style.display = "none";
});
dateSection.querySelector("#cancelChange").addEventListener("click", () => {
dateSection.querySelector("#changeDateForm").style.display = "none";
dateSection.querySelector("#changeDate").style.display = "";
});
dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection));
schedSection.querySelector("#saveSched").addEventListener("click", async () => {
const input = schedSection.querySelector("#schedInput");
const errorEl = schedSection.querySelector("#schedError");
errorEl.textContent = "";
// ОСНОВНАЯ кнопка — начать замер (открывает мастер с чек-листом)
const startSection = el(`
<div class="podbor-cta-row" style="margin-top:20px;">
<button class="btn-primary" id="startMeasure" style="font-size:16px;padding:14px 20px;">📐 Начать замер</button>
</div>
<div class="muted" style="text-align:center;font-size:12px;margin-top:8px;">
Чек-лист, фото и заметки откроются после нажатия.
</div>
`);
app.appendChild(startSection);
startSection.querySelector("#startMeasure").addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/measure?id=${measurementId}`;
});
} else {
// Дата не назначена — основной шаг: согласовать и назначить
const dateSection = el(`
<section class="block">
<div class="block-head">📞 Согласовать дату с клиентом</div>
<div style="padding:8px 4px;color:var(--muted);font-size:13px;">
Позвоните клиенту, договоритесь о точной дате и времени, затем зафиксируйте здесь.
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Дата и время визита</span>
<input type="datetime-local" id="schedInput">
<span class="field-error" id="schedError"></span>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-primary" id="saveSched" type="button">Назначить</button>
</div>
</section>
`);
app.appendChild(dateSection);
dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection));
}
}
async function saveScheduleDate(measurementId, section) {
const input = section.querySelector("#schedInput");
const errorEl = section.querySelector("#schedError");
if (errorEl) errorEl.textContent = "";
const val = input.value;
if (!val) {
errorEl.textContent = "Укажите дату и время";
if (errorEl) errorEl.textContent = "Укажите дату и время";
return;
}
const iso = new Date(val).toISOString();
@ -704,35 +745,22 @@ async function renderInboxDetail(measurementId) {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
measurement_id: measurementId,
scheduled_at: iso,
}),
});
const data = await res.json();
if (data.error) {
errorEl.textContent = "Ошибка: " + data.error;
if (errorEl) errorEl.textContent = "Ошибка: " + data.error;
return;
}
haptic && haptic("success");
tg?.showAlert?.("Дата назначена — менеджер уведомлён.");
renderInboxDetail(measurementId); // перерисовать
tg?.showAlert?.("Дата сохранена — менеджер уведомлён.");
renderInboxDetail(measurementId); // перерисовать с новым статусом
} catch (e) {
errorEl.textContent = "Сеть: " + e.message;
if (errorEl) 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 renderLogisticsBlock(m) {

View File

@ -2183,6 +2183,25 @@
border-left: 3px solid var(--walnut, #6B4A2B);
}
/* Блок «дата назначена» */
.date-set-block {
background: linear-gradient(180deg, rgba(0, 62, 126, 0.04), transparent);
border-left: 3px solid var(--accent-1, #003E7E);
}
.date-set-block .date-set-value {
font-family: var(--font-display, "Newsreader", serif);
font-style: italic;
font-size: 22px;
color: var(--accent-1, #003E7E);
padding: 8px 4px 12px;
font-weight: 500;
}
.date-set-block .date-set-form {
border-top: 1px dashed rgba(0, 62, 126, 0.2);
padding-top: 12px;
margin-top: 12px;
}
/* ===== Логистика (подъезд, GPS, парковка) ===== */
.logistics-block .block-head {
display: flex;

View File

@ -9,11 +9,8 @@ const MeasurementRequest = (function () {
client_phone: "",
address: "",
assigned_to_tg_id: "",
notes: "",
// Приблизительная дата визита
preferred_type: "tbd", // specific | this_week | next_week | tbd
preferred_date: "",
preferred_time_of_day: "", // morning | day | evening | ""
// Одно поле «Примечание» — рекомендации по дате замера + особенности.
// Замерщик увидит это в карточке заявки и согласует точное время с клиентом.
preferred_note: "",
};
let measurers = [];
@ -24,8 +21,8 @@ const MeasurementRequest = (function () {
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
state = {
client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", notes: "",
preferred_type: "tbd", preferred_date: "", preferred_time_of_day: "", preferred_note: "",
client_name: "", client_phone: "", address: "", assigned_to_tg_id: "",
preferred_note: "",
};
render();
loadMeasurers();
@ -75,53 +72,11 @@ const MeasurementRequest = (function () {
</label>
</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">
<label class="field">
<span class="field-label">Заметки для замерщика</span>
<textarea data-bind="notes" rows="3" placeholder="газ/электро, особые условия доступа, ниши под технику, ..."></textarea>
<span class="field-label">Примечание</span>
<textarea data-bind="preferred_note" rows="3" placeholder="например: эта неделя после звонка, не раньше вторника, удобно утром, газ/электро, особые условия доступа"></textarea>
<span class="field-hint">Рекомендации по дате + особенности. Точную дату согласует замерщик с клиентом.</span>
</label>
</div>
@ -147,23 +102,6 @@ const MeasurementRequest = (function () {
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() {
@ -224,12 +162,9 @@ const MeasurementRequest = (function () {
client_phone: phone,
address: state.address || "",
assigned_to_tg_id: state.assigned_to_tg_id || "",
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 || "",
preferred_type: "tbd",
}),
});
const data = await res.json();

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