feat: client picker in measurement form, address geocoding, edit-client 4-field addr

- measurements.js: replace manual name/phone inputs with client picker overlay
  (search by ФИО or phone, sorted alphabetically, picks from /api/clients)
- clients.js: new-client and edit-client forms now use 4 split address fields
  (город, улица, дом, кв./офис) with geocode validation on save
- clients.js: add splitAddress() helper to pre-fill edit form from stored address
- clients.js: voice engine refactored (continuous=false + auto-restart, no duplication)
- clients.js: note block view/edit toggle (textarea closes after save)
- podbor.css: styles for picker overlay, picker-row, chosen-card, addr-grid, geo-status
- backend main.py: _handle_client_create and _handle_client_update accept gps_lat/gps_lng
- index.html: cache bump → v=20260514i

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-14 14:51:25 +03:00
parent cbea202de5
commit 44799362c1
5 changed files with 528 additions and 82 deletions

View File

@ -2119,6 +2119,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
note = (body.get("note") or "").strip() note = (body.get("note") or "").strip()
contract_no = (body.get("contract_no") or "").strip() contract_no = (body.get("contract_no") or "").strip()
contract_date = (body.get("contract_date") or "").strip() contract_date = (body.get("contract_date") or "").strip()
gps_lat = body.get("gps_lat")
gps_lng = body.get("gps_lng")
# Валидация # Валидация
if not full_name: if not full_name:
@ -2152,6 +2154,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
client_no=str(client_no), client_no=str(client_no),
contract_no=contract_no, contract_no=contract_no,
contract_date=contract_date, contract_date=contract_date,
gps_lat=gps_lat,
gps_lng=gps_lng,
)) ))
# Сохраняем заметку в ClientNotes если она передана # Сохраняем заметку в ClientNotes если она передана
@ -2303,6 +2307,8 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
new_address = body.get("address") new_address = body.get("address")
new_contract_no = body.get("contract_no") new_contract_no = body.get("contract_no")
new_contract_date = body.get("contract_date") new_contract_date = body.get("contract_date")
new_gps_lat = body.get("gps_lat")
new_gps_lng = body.get("gps_lng")
if new_name and len(new_name) < 2: if new_name and len(new_name) < 2:
return {"error": "bad_name", "msg": "Имя слишком короткое"} return {"error": "bad_name", "msg": "Имя слишком короткое"}
@ -2337,6 +2343,8 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
address_col = col_idx("address") address_col = col_idx("address")
contract_no_col = col_idx("contract_no") contract_no_col = col_idx("contract_no")
contract_date_col = col_idx("contract_date") contract_date_col = col_idx("contract_date")
gps_lat_col = col_idx("gps_lat")
gps_lng_col = col_idx("gps_lng")
updated = 0 updated = 0
for i, r in enumerate(rows[1:], start=2): for i, r in enumerate(rows[1:], start=2):
@ -2357,6 +2365,10 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
ws.update_cell(i, contract_no_col, new_contract_no.strip()) ws.update_cell(i, contract_no_col, new_contract_no.strip())
if isinstance(new_contract_date, str) and contract_date_col: if isinstance(new_contract_date, str) and contract_date_col:
ws.update_cell(i, contract_date_col, new_contract_date.strip()) ws.update_cell(i, contract_date_col, new_contract_date.strip())
if new_gps_lat is not None and gps_lat_col:
ws.update_cell(i, gps_lat_col, new_gps_lat)
if new_gps_lng is not None and gps_lng_col:
ws.update_cell(i, gps_lng_col, new_gps_lng)
updated += 1 updated += 1
sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated}) sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated})

View File

@ -58,12 +58,27 @@ const Clients = (function () {
</label> </label>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="field-label">Адрес *</span>
<div class="addr-grid">
<label class="field"> <label class="field">
<span class="field-label">Адрес</span> <span class="field-sublabel">Город</span>
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12"> <input type="text" id="ad_city" placeholder="Санкт-Петербург" value="Санкт-Петербург" autocomplete="address-level2">
<span class="field-hint" id="addrHint">Укажите город, улицу, дом, кв.</span>
<span class="field-error" id="errAddr"></span>
</label> </label>
<label class="field">
<span class="field-sublabel">Улица</span>
<input type="text" id="ad_street" placeholder="пр. Просвещения" autocomplete="street-address">
</label>
<label class="field addr-house">
<span class="field-sublabel">Дом</span>
<input type="text" id="ad_house" placeholder="87" inputmode="text">
</label>
<label class="field addr-apt">
<span class="field-sublabel">Кв./офис</span>
<input type="text" id="ad_apt" placeholder="12" inputmode="numeric">
</label>
</div>
<span class="field-error" id="errAddr"></span>
<div class="geo-status" id="geoStatus"></div>
</div> </div>
<div class="form-row two-col"> <div class="form-row two-col">
<label class="field"> <label class="field">
@ -118,12 +133,19 @@ const Clients = (function () {
}); });
const name = (form.querySelector("#fn").value || "").trim(); const name = (form.querySelector("#fn").value || "").trim();
const phoneRaw = (form.querySelector("#ph").value || "").trim(); const phoneRaw = (form.querySelector("#ph").value || "").trim();
const address = (form.querySelector("#ad").value || "").trim(); const adCity = (form.querySelector("#ad_city").value || "").trim();
const adStreet = (form.querySelector("#ad_street").value|| "").trim();
const adHouse = (form.querySelector("#ad_house").value || "").trim();
const adApt = (form.querySelector("#ad_apt").value || "").trim();
const note = (form.querySelector("#nt").value || "").trim(); const note = (form.querySelector("#nt").value || "").trim();
const contract_no = (form.querySelector("#cn").value || "").trim(); const contract_no = (form.querySelector("#cn").value || "").trim();
const contract_date = (form.querySelector("#cd").value || "").trim(); const contract_date = (form.querySelector("#cd").value || "").trim();
// Валидация на клиенте // Собираем адрес из полей
const address = [adCity, adStreet, adHouse ? "д. " + adHouse : "", adApt ? "кв. " + adApt : ""]
.filter(Boolean).join(", ");
// Валидация
if (!name || name.length < 2) { if (!name || name.length < 2) {
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)"; form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
return; return;
@ -134,12 +156,39 @@ const Clients = (function () {
"Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)"; "Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)";
return; return;
} }
if (address && address.length < 5) { if (!adCity || !adStreet || !adHouse) {
form.querySelector("#errAddr").textContent = "Адрес слишком короткий — нужны улица + дом"; form.querySelector("#errAddr").textContent = "Укажите город, улицу и номер дома";
return; return;
} }
btn.disabled = true; btn.disabled = true;
btn.textContent = "Проверяем адрес…";
// Геокодирование — проверяем адрес, продолжаем даже при неудаче
let gps_lat = null, gps_lng = null;
const geoEl = form.querySelector("#geoStatus");
try {
const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
address: `${adCity}, ${adStreet}, д. ${adHouse}`,
city: adCity,
}),
});
const geoData = await geoRes.json();
if (geoData.ok && geoData.result) {
gps_lat = geoData.result.lat;
gps_lng = geoData.result.lng;
geoEl.innerHTML = `<span class="geo-ok">✓ ${escHtml(geoData.result.formatted || address)}</span>`;
} else {
geoEl.innerHTML = `<span class="geo-warn">⚠ Адрес не найден в геокодере — проверьте написание. Сохраняем без координат.</span>`;
}
} catch (_) {
geoEl.innerHTML = `<span class="geo-warn">⚠ Геокодер недоступен. Сохраняем без координат.</span>`;
}
btn.textContent = "Сохраняем..."; btn.textContent = "Сохраняем...";
try { try {
const res = await fetch(`${BACKEND_URL}/api/client_create`, { const res = await fetch(`${BACKEND_URL}/api/client_create`, {
@ -148,7 +197,7 @@ const Clients = (function () {
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
full_name: name, phone: norm.value, address, note, full_name: name, phone: norm.value, address, note,
contract_no, contract_date, contract_no, contract_date, gps_lat, gps_lng,
}), }),
}); });
const data = await res.json(); const data = await res.json();
@ -191,6 +240,25 @@ const Clients = (function () {
}); });
} }
// Разбирает сохранённый адрес «Город, Улица, д. NN, кв. MM» обратно в поля.
function splitAddress(combined) {
if (!combined) return { city: "Санкт-Петербург", street: "", house: "", apt: "" };
let s = combined.trim();
let apt = "";
const aptMatch = s.match(/,\s*кв\.?\s*([^\s,]+)/i);
if (aptMatch) { apt = aptMatch[1]; s = s.replace(aptMatch[0], ""); }
let house = "";
const houseMatch = s.match(/,\s*д\.?\s*([^\s,]+)/i);
if (houseMatch) { house = houseMatch[1]; s = s.replace(houseMatch[0], ""); }
s = s.replace(/,$/, "").trim();
const parts = s.split(",").map(p => p.trim()).filter(Boolean);
let city = "", street = "";
if (parts.length >= 2) { city = parts[0]; street = parts.slice(1).join(", "); }
else if (parts.length === 1) { city = parts[0]; }
if (!city) city = "Санкт-Петербург";
return { city, street, house, apt };
}
function normalizePhone(raw) { function normalizePhone(raw) {
if (!raw) return { ok: false, value: "" }; if (!raw) return { ok: false, value: "" };
const digits = String(raw).replace(/\D/g, ""); const digits = String(raw).replace(/\D/g, "");
@ -644,6 +712,7 @@ const Clients = (function () {
root.innerHTML = ""; root.innerHTML = "";
root.appendChild(headerEl("Редактировать клиента", "#/clients")); root.appendChild(headerEl("Редактировать клиента", "#/clients"));
const addrParts = splitAddress(client.address || "");
const form = el(` const form = el(`
<section class="podbor-step"> <section class="podbor-step">
<h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2> <h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2>
@ -664,10 +733,27 @@ const Clients = (function () {
</label> </label>
</div> </div>
<div class="form-row"> <div class="form-row">
<label class="field">
<span class="field-label">Адрес</span> <span class="field-label">Адрес</span>
<input type="text" id="ed_addr" value="${escAttr(client.address || "")}" placeholder="СПб, Просвещения 87, кв. 12"> <div class="addr-grid">
<label class="field">
<span class="field-sublabel">Город</span>
<input type="text" id="ed_city" value="${escAttr(addrParts.city)}" placeholder="Санкт-Петербург" autocomplete="address-level2">
</label> </label>
<label class="field">
<span class="field-sublabel">Улица</span>
<input type="text" id="ed_street" value="${escAttr(addrParts.street)}" placeholder="пр. Просвещения" autocomplete="street-address">
</label>
<label class="field addr-house">
<span class="field-sublabel">Дом</span>
<input type="text" id="ed_house" value="${escAttr(addrParts.house)}" placeholder="87" inputmode="text">
</label>
<label class="field addr-apt">
<span class="field-sublabel">Кв./офис</span>
<input type="text" id="ed_apt" value="${escAttr(addrParts.apt)}" placeholder="12" inputmode="numeric">
</label>
</div>
<span class="field-error" id="ed_errAddr"></span>
<div class="geo-status" id="ed_geoStatus"></div>
</div> </div>
<div class="form-row two-col"> <div class="form-row two-col">
<label class="field"> <label class="field">
@ -697,13 +783,17 @@ const Clients = (function () {
form.querySelector("#ed_save").addEventListener("click", async () => { form.querySelector("#ed_save").addEventListener("click", async () => {
const fn = form.querySelector("#ed_fn").value.trim(); const fn = form.querySelector("#ed_fn").value.trim();
const ph = form.querySelector("#ed_ph").value.trim(); const ph = form.querySelector("#ed_ph").value.trim();
const addr = form.querySelector("#ed_addr").value.trim(); const edCity = (form.querySelector("#ed_city").value || "").trim();
const edStreet = (form.querySelector("#ed_street").value || "").trim();
const edHouse = (form.querySelector("#ed_house").value || "").trim();
const edApt = (form.querySelector("#ed_apt").value || "").trim();
const cno = form.querySelector("#ed_cno").value.trim(); const cno = form.querySelector("#ed_cno").value.trim();
const cdate = form.querySelector("#ed_cdate").value.trim(); const cdate = form.querySelector("#ed_cdate").value.trim();
const errName = form.querySelector("#ed_errName"); const errName = form.querySelector("#ed_errName");
const errPhone = form.querySelector("#ed_errPhone"); const errPhone = form.querySelector("#ed_errPhone");
const errAddr = form.querySelector("#ed_errAddr");
const result = form.querySelector("#ed_result"); const result = form.querySelector("#ed_result");
errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = ""; errName.textContent = ""; errPhone.textContent = ""; errAddr.textContent = ""; result.innerHTML = "";
if (!fn || fn.length < 2) { if (!fn || fn.length < 2) {
errName.textContent = "Имя слишком короткое"; errName.textContent = "Имя слишком короткое";
@ -714,14 +804,43 @@ const Clients = (function () {
errPhone.textContent = "Телефон в формате +7XXXXXXXXXX"; errPhone.textContent = "Телефон в формате +7XXXXXXXXXX";
return; return;
} }
if (addr && addr.length < 5) { if (!edCity || !edStreet || !edHouse) {
result.innerHTML = `<span style="color:#C0392B;">Адрес слишком короткий</span>`; errAddr.textContent = "Укажите город, улицу и номер дома";
return; return;
} }
const btn = form.querySelector("#ed_save"); const address = [edCity, edStreet, edHouse ? "д. " + edHouse : "", edApt ? "кв. " + edApt : ""]
btn.disabled = true; btn.textContent = "Сохраняем..."; .filter(Boolean).join(", ");
const btn = form.querySelector("#ed_save");
btn.disabled = true; btn.textContent = "Проверяем адрес…";
// Геокодирование — необязательно, продолжаем даже при неудаче
let gps_lat = null, gps_lng = null;
const geoEl = form.querySelector("#ed_geoStatus");
try {
const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
address: `${edCity}, ${edStreet}, д. ${edHouse}`,
city: edCity,
}),
});
const geoData = await geoRes.json();
if (geoData.ok && geoData.result) {
gps_lat = geoData.result.lat;
gps_lng = geoData.result.lng;
geoEl.innerHTML = `<span class="geo-ok">✓ ${escHtml(geoData.result.formatted || address)}</span>`;
} else {
geoEl.innerHTML = `<span class="geo-warn">⚠ Адрес не найден — сохраняем без координат.</span>`;
}
} catch (_) {
geoEl.innerHTML = `<span class="geo-warn">⚠ Геокодер недоступен. Сохраняем без координат.</span>`;
}
btn.textContent = "Сохраняем...";
try { try {
const res = await fetch(`${BACKEND_URL}/api/client_update`, { const res = await fetch(`${BACKEND_URL}/api/client_update`, {
method: "POST", method: "POST",
@ -731,9 +850,11 @@ const Clients = (function () {
client_key: (client.client_name || "").toLowerCase(), client_key: (client.client_name || "").toLowerCase(),
full_name: fn, full_name: fn,
phone: norm.value, phone: norm.value,
address: addr, address,
contract_no: cno, contract_no: cno,
contract_date: cdate, contract_date: cdate,
gps_lat,
gps_lng,
}), }),
}); });
const data = await res.json(); const data = await res.json();

View File

@ -28,6 +28,7 @@ const Measurements = (function () {
let root = null; let root = null;
let measurementId = ""; // если задан — update-mode (закрытие заявки) let measurementId = ""; // если задан — update-mode (закрытие заявки)
let prefilledClient = null; let prefilledClient = null;
let pickedClient = null; // клиент из пикера (только для нового замера)
function loadState() { function loadState() {
try { try {
@ -60,6 +61,7 @@ const Measurements = (function () {
saveState(); saveState();
photos = []; photos = [];
prefilledClient = null; prefilledClient = null;
pickedClient = null;
} }
/* ===================== Mount + Render ===================== */ /* ===================== Mount + Render ===================== */
@ -73,6 +75,7 @@ const Measurements = (function () {
photos = []; photos = [];
measurementId = ""; measurementId = "";
prefilledClient = null; prefilledClient = null;
pickedClient = null;
const hashMatch = (location.hash.split("?")[1] || ""); const hashMatch = (location.hash.split("?")[1] || "");
const fragQp = new URLSearchParams(hashMatch); const fragQp = new URLSearchParams(hashMatch);
@ -155,7 +158,7 @@ const Measurements = (function () {
function renderForm() { function renderForm() {
const isUpdate = !!measurementId && prefilledClient; const isUpdate = !!measurementId && prefilledClient;
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs(); const clientBlock = isUpdate ? renderClientReadOnly() : renderClientPicker();
const node = el(` const node = el(`
<section class="podbor-step"> <section class="podbor-step">
@ -363,31 +366,164 @@ const Measurements = (function () {
`); `);
} }
function renderClientInputs() { /* ===================== Пикер клиента ===================== */
return el(`
<div> function renderClientPicker() {
const wrap = el(`
<div class="client-picker-wrap">
<div class="form-row" id="pcChoiceRow"></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>
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович"> <input type="text" data-bind="address" id="pcAddr"
<span class="field-error" id="nameError"></span> value="${escAttr(state.address)}"
</label> placeholder="СПб, пр. Просвещения, д. 87, кв. 12">
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Телефон *</span>
<input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 921 555-12-34">
<span class="field-error" id="phoneError"></span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Адрес</span>
<input type="text" data-bind="address" value="${escAttr(state.address)}" placeholder="СПб, Просвещения 87, кв. 12">
</label> </label>
</div> </div>
</div> </div>
`); `);
const choiceRow = wrap.querySelector("#pcChoiceRow");
const addrInput = wrap.querySelector("#pcAddr");
addrInput.addEventListener("input", e => {
state.address = e.target.value;
saveState();
});
function refresh() {
choiceRow.innerHTML = "";
if (pickedClient) {
const card = el(`
<div class="picker-chosen-card">
<div class="picker-chosen-info">
<div class="picker-chosen-name">${escHtml(pickedClient.client_name)}</div>
<div class="picker-chosen-sub">
${escHtml(pickedClient.client_phone || "")}${pickedClient.contract_no ? " · дог. " + escHtml(pickedClient.contract_no) : ""}
</div>
</div>
<button class="picker-change-btn" type="button">Изменить</button>
</div>
`);
card.querySelector(".picker-change-btn").addEventListener("click", openOverlay);
choiceRow.appendChild(card);
if (!addrInput.value && pickedClient.address) {
addrInput.value = pickedClient.address;
state.address = pickedClient.address;
saveState();
}
} else {
const empty = el(`
<div class="picker-empty-state">
<button class="picker-open-btn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
Выбрать клиента из CRM
</button>
<span class="field-error" id="nameError"></span>
</div>
`);
empty.querySelector(".picker-open-btn").addEventListener("click", openOverlay);
choiceRow.appendChild(empty);
}
}
async function openOverlay() {
const overlay = el(`
<div class="client-picker-overlay">
<div class="picker-sheet">
<div class="picker-sheet-head">
<span class="picker-sheet-title">Выбор клиента</span>
<button class="picker-close-btn" type="button"></button>
</div>
<div class="picker-search-wrap">
<input class="picker-search" type="search"
placeholder="Поиск по ФИО или телефону…" autocomplete="off">
</div>
<div class="picker-list" id="pcList">
<div class="picker-loading">Загружаем список</div>
</div>
</div>
</div>
`);
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add("open"));
const listEl = overlay.querySelector("#pcList");
const searchEl = overlay.querySelector(".picker-search");
function closeOverlay() {
overlay.classList.remove("open");
setTimeout(() => overlay.remove(), 220);
}
overlay.querySelector(".picker-close-btn").addEventListener("click", closeOverlay);
overlay.addEventListener("click", e => { if (e.target === overlay) closeOverlay(); });
let clients = [];
try {
const res = await fetch(`${BACKEND_URL}/api/clients`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
}),
});
const data = await res.json();
clients = (data.clients || []).sort((a, b) =>
(a.client_name || "").localeCompare(b.client_name || "", "ru")
);
} catch (e) {
listEl.innerHTML = `<div class="picker-error">Ошибка загрузки: ${escHtml(e.message)}</div>`;
return;
}
function renderList(q) {
q = (q || "").toLowerCase().trim();
const filtered = q
? clients.filter(c =>
(c.client_name || "").toLowerCase().includes(q) ||
(c.client_phone || "").replace(/\D/g, "").includes(q.replace(/\D/g, ""))
)
: clients;
listEl.innerHTML = "";
if (!filtered.length) {
listEl.innerHTML = `<div class="picker-empty-msg">${q ? "Ничего не найдено" : "Список клиентов пуст"}</div>`;
return;
}
filtered.forEach(c => {
const row = el(`
<div class="picker-row">
<div class="picker-row-name">${escHtml(c.client_name || "—")}</div>
<div class="picker-row-sub">
${c.client_phone ? `<span>${escHtml(c.client_phone)}</span>` : ""}
${c.contract_no ? `<span class="picker-contract">дог. ${escHtml(c.contract_no)}</span>` : ""}
</div>
</div>
`);
row.addEventListener("click", () => {
pickedClient = {
client_name: c.client_name || "",
client_phone: c.client_phone || "",
address: c.address || "",
contract_no: c.contract_no || "",
client_no: c.client_no || "",
};
closeOverlay();
refresh();
});
listEl.appendChild(row);
});
}
renderList("");
searchEl.focus();
searchEl.addEventListener("input", () => renderList(searchEl.value));
}
refresh();
return wrap;
} }
function bindInputs(node) { function bindInputs(node) {
@ -703,19 +839,12 @@ const Measurements = (function () {
const isUpdate = !!measurementId && prefilledClient; const isUpdate = !!measurementId && prefilledClient;
if (!isUpdate) { if (!isUpdate) {
const name = (state.client_name || "").trim(); if (!pickedClient) {
const phone = (state.client_phone || "").trim();
const nameErr = node.querySelector("#nameError"); const nameErr = node.querySelector("#nameError");
const phoneErr = node.querySelector("#phoneError"); if (nameErr) {
if (nameErr) nameErr.textContent = ""; nameErr.textContent = "Выберите клиента из списка";
if (phoneErr) phoneErr.textContent = ""; nameErr.scrollIntoView({ behavior: "smooth", block: "center" });
if (!name) {
if (nameErr) nameErr.textContent = "Укажите имя клиента";
btn.disabled = false; btn.textContent = "Сохранить замер";
return;
} }
if (phone.replace(/\D/g, "").length < 10) {
if (phoneErr) phoneErr.textContent = "Слишком короткий номер";
btn.disabled = false; btn.textContent = "Сохранить замер"; btn.disabled = false; btn.textContent = "Сохранить замер";
return; return;
} }
@ -735,8 +864,8 @@ const Measurements = (function () {
zamer_date: state.zamer_date || "", zamer_date: state.zamer_date || "",
notes: state.notes || "", notes: state.notes || "",
// Клиент // Клиент
client_name: isUpdate ? prefilledClient.name : state.client_name, client_name: isUpdate ? prefilledClient.name : pickedClient.client_name,
client_phone: isUpdate ? prefilledClient.phone : state.client_phone, client_phone: isUpdate ? prefilledClient.phone : pickedClient.client_phone,
address: isUpdate ? prefilledClient.address : state.address, address: isUpdate ? prefilledClient.address : state.address,
measurement_id: measurementId || undefined, measurement_id: measurementId || undefined,
}; };

View File

@ -3320,6 +3320,190 @@
border-top: 1px dashed var(--line); border-top: 1px dashed var(--line);
} }
/* ===== Поля адреса (addr-grid) ===== */
.addr-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 6px;
}
.addr-grid .field { margin: 0; }
.addr-house { grid-column: 1; }
.addr-apt { grid-column: 2; }
.field-sublabel {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--muted, #998877);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 3px;
}
.geo-status {
font-size: 12.5px;
line-height: 1.4;
min-height: 18px;
margin-top: 5px;
}
.geo-ok { color: #27AE60; }
.geo-warn { color: #C0392B; }
/* ===== Пикер клиента (замер) ===== */
.client-picker-wrap { margin-bottom: 2px; }
.picker-open-btn {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: var(--paper, #FBF7F0);
border: 1.5px dashed rgba(107,74,43,0.28);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: var(--walnut, #6B4A2B);
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.picker-open-btn:active { background: rgba(107,74,43,0.06); }
.picker-open-btn .picker-open-icon {
width: 32px; height: 32px; flex-shrink: 0;
border-radius: 50%;
background: linear-gradient(145deg, #845A2E 0%, #5C3A1C 100%);
display: flex; align-items: center; justify-content: center;
color: #F5DAAA; font-size: 16px;
}
.picker-chosen-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--paper, #FBF7F0);
border: 1.5px solid rgba(107,74,43,0.22);
border-radius: 10px;
}
.picker-chosen-info { flex: 1; min-width: 0; }
.picker-chosen-name {
font-size: 14px;
font-weight: 600;
color: var(--ink, #1F1A14);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-chosen-sub {
font-size: 12px;
color: var(--muted, #998877);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-change-btn {
flex-shrink: 0;
padding: 5px 10px;
background: transparent;
border: 1px solid rgba(107,74,43,0.28);
border-radius: 16px;
font-size: 12px;
font-weight: 500;
color: var(--walnut, #6B4A2B);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.picker-change-btn:active { background: rgba(107,74,43,0.08); }
/* Оверлей пикера */
.client-picker-overlay {
position: fixed;
inset: 0;
z-index: 800;
background: rgba(20,15,10,0.45);
display: flex;
align-items: flex-end;
}
.picker-sheet {
width: 100%;
max-height: 80vh;
background: var(--bg, #F5EFE6);
border-radius: 18px 18px 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 -4px 24px rgba(0,0,0,0.18);
}
.picker-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--line, rgba(107,74,43,0.12));
flex-shrink: 0;
}
.picker-sheet-title {
font-size: 15px;
font-weight: 700;
color: var(--ink, #1F1A14);
}
.picker-sheet-close {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: rgba(107,74,43,0.09);
border: none; border-radius: 50%;
font-size: 16px; cursor: pointer;
color: var(--walnut, #6B4A2B);
font-family: inherit;
}
.picker-search-wrap {
padding: 10px 14px;
flex-shrink: 0;
}
.picker-search {
width: 100%;
padding: 9px 12px;
background: white;
border: 1px solid rgba(107,74,43,0.18);
border-radius: 8px;
font-family: inherit;
font-size: 14px;
color: var(--ink, #1F1A14);
box-sizing: border-box;
}
.picker-search:focus { outline: none; border-color: var(--walnut, #6B4A2B); }
.picker-list {
flex: 1;
overflow-y: auto;
padding: 4px 0 env(safe-area-inset-bottom, 16px);
}
.picker-row {
display: flex;
flex-direction: column;
padding: 11px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(107,74,43,0.07);
transition: background 0.1s;
}
.picker-row:active { background: rgba(107,74,43,0.06); }
.picker-row-name {
font-size: 14px;
font-weight: 600;
color: var(--ink, #1F1A14);
}
.picker-row-sub {
font-size: 12px;
color: var(--muted, #998877);
margin-top: 1px;
}
.picker-empty-state {
text-align: center;
padding: 32px 16px;
color: var(--muted, #998877);
font-size: 14px;
}
/* ===== Печать / PDF ===== */ /* ===== Печать / PDF ===== */
@media print { @media print {
body { background: white !important; color: black !important; } body { background: white !important; color: black !important; }

View File

@ -12,14 +12,14 @@
<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;800&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&family=Caveat:wght@500;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&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&family=Caveat:wght@500;700&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=20260514h"> <link rel="stylesheet" href="assets/styles.css?v=20260514i">
<link rel="stylesheet" href="assets/podbor.css?v=20260514h"> <link rel="stylesheet" href="assets/podbor.css?v=20260514i">
</head> </head>
<body> <body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск --> <!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash"> <div class="loader splash" id="splash">
<div class="brand-logo-wrap"> <div class="brand-logo-wrap">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514h" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514i" alt="@wasrusgen1">
<div class="splash-dust" aria-hidden="true"> <div class="splash-dust" aria-hidden="true">
<span class="dust d1"></span> <span class="dust d2"></span> <span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span> <span class="dust d3"></span> <span class="dust d4"></span>
@ -35,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div> <div class="brand-tagline-gold">CRM</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260514h"></script> <script src="assets/icons.js?v=20260514i"></script>
<script src="assets/podbor.config.js?v=20260514h"></script> <script src="assets/podbor.config.js?v=20260514i"></script>
<script src="assets/podbor.picts.js?v=20260514h"></script> <script src="assets/podbor.picts.js?v=20260514i"></script>
<script src="assets/podbor.js?v=20260514h"></script> <script src="assets/podbor.js?v=20260514i"></script>
<script src="assets/clients.js?v=20260514h"></script> <script src="assets/clients.js?v=20260514i"></script>
<script src="assets/zamer-picts.js?v=20260514h"></script> <script src="assets/zamer-picts.js?v=20260514i"></script>
<script src="assets/measurements.js?v=20260514h"></script> <script src="assets/measurements.js?v=20260514i"></script>
<script src="assets/request.js?v=20260514h"></script> <script src="assets/request.js?v=20260514i"></script>
<script src="assets/assembly.js?v=20260514h"></script> <script src="assets/assembly.js?v=20260514i"></script>
<script src="assets/app.js?v=20260514h"></script> <script src="assets/app.js?v=20260514i"></script>
</body> </body>
</html> </html>