mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
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:
parent
cbea202de5
commit
44799362c1
@ -2119,6 +2119,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||
note = (body.get("note") or "").strip()
|
||||
contract_no = (body.get("contract_no") 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:
|
||||
@ -2152,6 +2154,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||
client_no=str(client_no),
|
||||
contract_no=contract_no,
|
||||
contract_date=contract_date,
|
||||
gps_lat=gps_lat,
|
||||
gps_lng=gps_lng,
|
||||
))
|
||||
|
||||
# Сохраняем заметку в ClientNotes если она передана
|
||||
@ -2303,6 +2307,8 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
|
||||
new_address = body.get("address")
|
||||
new_contract_no = body.get("contract_no")
|
||||
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:
|
||||
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")
|
||||
contract_no_col = col_idx("contract_no")
|
||||
contract_date_col = col_idx("contract_date")
|
||||
gps_lat_col = col_idx("gps_lat")
|
||||
gps_lng_col = col_idx("gps_lng")
|
||||
|
||||
updated = 0
|
||||
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())
|
||||
if isinstance(new_contract_date, str) and contract_date_col:
|
||||
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
|
||||
|
||||
sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated})
|
||||
|
||||
@ -58,12 +58,27 @@ const Clients = (function () {
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span class="field-label">Адрес</span>
|
||||
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12">
|
||||
<span class="field-hint" id="addrHint">Укажите город, улицу, дом, кв.</span>
|
||||
<span class="field-error" id="errAddr"></span>
|
||||
</label>
|
||||
<span class="field-label">Адрес *</span>
|
||||
<div class="addr-grid">
|
||||
<label class="field">
|
||||
<span class="field-sublabel">Город</span>
|
||||
<input type="text" id="ad_city" placeholder="Санкт-Петербург" value="Санкт-Петербург" autocomplete="address-level2">
|
||||
</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 class="form-row two-col">
|
||||
<label class="field">
|
||||
@ -116,14 +131,21 @@ const Clients = (function () {
|
||||
const e = form.querySelector("#" + id);
|
||||
if (e) e.textContent = "";
|
||||
});
|
||||
const name = (form.querySelector("#fn").value || "").trim();
|
||||
const phoneRaw = (form.querySelector("#ph").value || "").trim();
|
||||
const address = (form.querySelector("#ad").value || "").trim();
|
||||
const note = (form.querySelector("#nt").value || "").trim();
|
||||
const contract_no = (form.querySelector("#cn").value || "").trim();
|
||||
const contract_date = (form.querySelector("#cd").value || "").trim();
|
||||
const name = (form.querySelector("#fn").value || "").trim();
|
||||
const phoneRaw = (form.querySelector("#ph").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 contract_no = (form.querySelector("#cn").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) {
|
||||
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
|
||||
return;
|
||||
@ -134,12 +156,39 @@ const Clients = (function () {
|
||||
"Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)";
|
||||
return;
|
||||
}
|
||||
if (address && address.length < 5) {
|
||||
form.querySelector("#errAddr").textContent = "Адрес слишком короткий — нужны улица + дом";
|
||||
if (!adCity || !adStreet || !adHouse) {
|
||||
form.querySelector("#errAddr").textContent = "Укажите город, улицу и номер дома";
|
||||
return;
|
||||
}
|
||||
|
||||
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 = "Сохраняем...";
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/client_create`, {
|
||||
@ -148,7 +197,7 @@ const Clients = (function () {
|
||||
initData: tg?.initData || "",
|
||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||
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();
|
||||
@ -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) {
|
||||
if (!raw) return { ok: false, value: "" };
|
||||
const digits = String(raw).replace(/\D/g, "");
|
||||
@ -644,6 +712,7 @@ const Clients = (function () {
|
||||
root.innerHTML = "";
|
||||
root.appendChild(headerEl("Редактировать клиента", "#/clients"));
|
||||
|
||||
const addrParts = splitAddress(client.address || "");
|
||||
const form = el(`
|
||||
<section class="podbor-step">
|
||||
<h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2>
|
||||
@ -664,10 +733,27 @@ const Clients = (function () {
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span class="field-label">Адрес</span>
|
||||
<input type="text" id="ed_addr" value="${escAttr(client.address || "")}" placeholder="СПб, Просвещения 87, кв. 12">
|
||||
</label>
|
||||
<span class="field-label">Адрес</span>
|
||||
<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 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 class="form-row two-col">
|
||||
<label class="field">
|
||||
@ -695,15 +781,19 @@ const Clients = (function () {
|
||||
});
|
||||
|
||||
form.querySelector("#ed_save").addEventListener("click", async () => {
|
||||
const fn = form.querySelector("#ed_fn").value.trim();
|
||||
const ph = form.querySelector("#ed_ph").value.trim();
|
||||
const addr = form.querySelector("#ed_addr").value.trim();
|
||||
const cno = form.querySelector("#ed_cno").value.trim();
|
||||
const cdate = form.querySelector("#ed_cdate").value.trim();
|
||||
const errName = form.querySelector("#ed_errName");
|
||||
const fn = form.querySelector("#ed_fn").value.trim();
|
||||
const ph = form.querySelector("#ed_ph").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 cdate = form.querySelector("#ed_cdate").value.trim();
|
||||
const errName = form.querySelector("#ed_errName");
|
||||
const errPhone = form.querySelector("#ed_errPhone");
|
||||
const result = form.querySelector("#ed_result");
|
||||
errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = "";
|
||||
const errAddr = form.querySelector("#ed_errAddr");
|
||||
const result = form.querySelector("#ed_result");
|
||||
errName.textContent = ""; errPhone.textContent = ""; errAddr.textContent = ""; result.innerHTML = "";
|
||||
|
||||
if (!fn || fn.length < 2) {
|
||||
errName.textContent = "Имя слишком короткое";
|
||||
@ -714,14 +804,43 @@ const Clients = (function () {
|
||||
errPhone.textContent = "Телефон в формате +7XXXXXXXXXX";
|
||||
return;
|
||||
}
|
||||
if (addr && addr.length < 5) {
|
||||
result.innerHTML = `<span style="color:#C0392B;">Адрес слишком короткий</span>`;
|
||||
if (!edCity || !edStreet || !edHouse) {
|
||||
errAddr.textContent = "Укажите город, улицу и номер дома";
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = form.querySelector("#ed_save");
|
||||
btn.disabled = true; btn.textContent = "Сохраняем...";
|
||||
const address = [edCity, edStreet, edHouse ? "д. " + edHouse : "", edApt ? "кв. " + edApt : ""]
|
||||
.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 {
|
||||
const res = await fetch(`${BACKEND_URL}/api/client_update`, {
|
||||
method: "POST",
|
||||
@ -731,9 +850,11 @@ const Clients = (function () {
|
||||
client_key: (client.client_name || "").toLowerCase(),
|
||||
full_name: fn,
|
||||
phone: norm.value,
|
||||
address: addr,
|
||||
address,
|
||||
contract_no: cno,
|
||||
contract_date: cdate,
|
||||
gps_lat,
|
||||
gps_lng,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@ -28,6 +28,7 @@ const Measurements = (function () {
|
||||
let root = null;
|
||||
let measurementId = ""; // если задан — update-mode (закрытие заявки)
|
||||
let prefilledClient = null;
|
||||
let pickedClient = null; // клиент из пикера (только для нового замера)
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
@ -60,6 +61,7 @@ const Measurements = (function () {
|
||||
saveState();
|
||||
photos = [];
|
||||
prefilledClient = null;
|
||||
pickedClient = null;
|
||||
}
|
||||
|
||||
/* ===================== Mount + Render ===================== */
|
||||
@ -73,6 +75,7 @@ const Measurements = (function () {
|
||||
photos = [];
|
||||
measurementId = "";
|
||||
prefilledClient = null;
|
||||
pickedClient = null;
|
||||
|
||||
const hashMatch = (location.hash.split("?")[1] || "");
|
||||
const fragQp = new URLSearchParams(hashMatch);
|
||||
@ -155,7 +158,7 @@ const Measurements = (function () {
|
||||
|
||||
function renderForm() {
|
||||
const isUpdate = !!measurementId && prefilledClient;
|
||||
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs();
|
||||
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientPicker();
|
||||
|
||||
const node = el(`
|
||||
<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">
|
||||
<label class="field">
|
||||
<span class="field-label">ФИО клиента *</span>
|
||||
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович">
|
||||
<span class="field-error" id="nameError"></span>
|
||||
</label>
|
||||
</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">
|
||||
<span class="field-label">Адрес объекта</span>
|
||||
<input type="text" data-bind="address" id="pcAddr"
|
||||
value="${escAttr(state.address)}"
|
||||
placeholder="СПб, пр. Просвещения, д. 87, кв. 12">
|
||||
</label>
|
||||
</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) {
|
||||
@ -703,19 +839,12 @@ const Measurements = (function () {
|
||||
|
||||
const isUpdate = !!measurementId && prefilledClient;
|
||||
if (!isUpdate) {
|
||||
const name = (state.client_name || "").trim();
|
||||
const phone = (state.client_phone || "").trim();
|
||||
const nameErr = node.querySelector("#nameError");
|
||||
const phoneErr = node.querySelector("#phoneError");
|
||||
if (nameErr) nameErr.textContent = "";
|
||||
if (phoneErr) phoneErr.textContent = "";
|
||||
if (!name) {
|
||||
if (nameErr) nameErr.textContent = "Укажите имя клиента";
|
||||
btn.disabled = false; btn.textContent = "Сохранить замер";
|
||||
return;
|
||||
}
|
||||
if (phone.replace(/\D/g, "").length < 10) {
|
||||
if (phoneErr) phoneErr.textContent = "Слишком короткий номер";
|
||||
if (!pickedClient) {
|
||||
const nameErr = node.querySelector("#nameError");
|
||||
if (nameErr) {
|
||||
nameErr.textContent = "Выберите клиента из списка";
|
||||
nameErr.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
btn.disabled = false; btn.textContent = "Сохранить замер";
|
||||
return;
|
||||
}
|
||||
@ -735,9 +864,9 @@ const Measurements = (function () {
|
||||
zamer_date: state.zamer_date || "",
|
||||
notes: state.notes || "",
|
||||
// Клиент
|
||||
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
||||
client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
|
||||
address: isUpdate ? prefilledClient.address : state.address,
|
||||
client_name: isUpdate ? prefilledClient.name : pickedClient.client_name,
|
||||
client_phone: isUpdate ? prefilledClient.phone : pickedClient.client_phone,
|
||||
address: isUpdate ? prefilledClient.address : state.address,
|
||||
measurement_id: measurementId || undefined,
|
||||
};
|
||||
|
||||
|
||||
@ -3320,6 +3320,190 @@
|
||||
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 ===== */
|
||||
@media print {
|
||||
body { background: white !important; color: black !important; }
|
||||
|
||||
@ -12,14 +12,14 @@
|
||||
<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">
|
||||
<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/podbor.css?v=20260514h">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260514i">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514i">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||
<div class="loader splash" id="splash">
|
||||
<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">
|
||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||
@ -35,15 +35,15 @@
|
||||
<div class="brand-tagline-gold">CRM</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260514h"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514h"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514h"></script>
|
||||
<script src="assets/podbor.js?v=20260514h"></script>
|
||||
<script src="assets/clients.js?v=20260514h"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514h"></script>
|
||||
<script src="assets/measurements.js?v=20260514h"></script>
|
||||
<script src="assets/request.js?v=20260514h"></script>
|
||||
<script src="assets/assembly.js?v=20260514h"></script>
|
||||
<script src="assets/app.js?v=20260514h"></script>
|
||||
<script src="assets/icons.js?v=20260514i"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514i"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514i"></script>
|
||||
<script src="assets/podbor.js?v=20260514i"></script>
|
||||
<script src="assets/clients.js?v=20260514i"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514i"></script>
|
||||
<script src="assets/measurements.js?v=20260514i"></script>
|
||||
<script src="assets/request.js?v=20260514i"></script>
|
||||
<script src="assets/assembly.js?v=20260514i"></script>
|
||||
<script src="assets/app.js?v=20260514i"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user