mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
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:
parent
9e23239f57
commit
366625be66
@ -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"Откройте кабинет — согласуйте точную дату с клиентом."
|
||||
)
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user