diff --git a/backend-py/app/main.py b/backend-py/app/main.py
index 1adb9a3..40af6fc 100644
--- a/backend-py/app/main.py
+++ b/backend-py/app/main.py
@@ -86,6 +86,7 @@ async def _dispatch_post(request: Request):
handlers = {
"me": _handle_me,
"measurement": _handle_measurement,
+ "measurements": _handle_measurements_list,
"podbor": _handle_podbor,
"clients": _handle_clients,
"lead": _handle_lead,
@@ -149,6 +150,12 @@ async def api_lead(request: Request):
return _handle_lead(body)
+@app.post("/api/measurements")
+async def api_measurements(request: Request):
+ body = await _safe_json(request)
+ return _handle_measurements_list(body)
+
+
@app.get("/api/test_ai")
async def api_test_ai():
return _handle_test_ai()
@@ -408,6 +415,16 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
sheets.find_row("Clients", "tg_id", tg_id) or {}
).get("manager_tg_id", "")
+ # Прикрепляем имя/телефон клиента к notes если client_tg_id нет (новый клиент)
+ notes_full = m.get("notes", "")
+ extras = []
+ if m.get("client_name"):
+ extras.append(f"Клиент: {m['client_name']}")
+ if m.get("client_phone"):
+ extras.append(f"Тел: {m['client_phone']}")
+ if extras:
+ notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
+
sheets.append_row("Measurements", [
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
filled_by,
@@ -417,7 +434,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
json.dumps(m.get("infra") or {}, ensure_ascii=False),
json.dumps(m.get("niches") or {}, ensure_ascii=False),
",".join(m.get("photos") or []),
- m.get("notes", ""),
+ notes_full,
"submitted",
])
@@ -661,6 +678,60 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
}
+def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
+ """Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name."""
+ cfg = get_config()
+ auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
+ if not auth or not auth.get("user"):
+ return {"error": "invalid_init_data"}
+ tg_id = auth["user"]["id"]
+ user = sheets.find_user(tg_id)
+ if not user or user.get("role") != "manager":
+ return {"error": "only_manager"}
+
+ client_tg_id = body.get("client_tg_id") or ""
+ client_name = (body.get("client_name") or "").strip().lower()
+
+ try:
+ ws = sheets.sheet("Measurements")
+ rows = ws.get_all_values()
+ except Exception as e:
+ log.warning("Failed to read Measurements: %s", e)
+ return {"ok": True, "measurements": []}
+
+ if not rows or len(rows) < 2:
+ return {"ok": True, "measurements": []}
+
+ headers = rows[0]
+ out = []
+ for r in rows[1:]:
+ row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
+ if str(row.get("manager_tg_id", "")) != str(tg_id):
+ continue
+ # Опциональные фильтры по клиенту
+ if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id):
+ continue
+ # Из notes / других JSON-полей вытащим client_name если был передан в measurement
+ # (он не сохраняется в отдельной колонке — только в JSON-обвязке)
+ # Для MVP — фильтр по имени делаем после парсинга JSON-полей
+ out.append({
+ "id": row.get("id", ""),
+ "created_at": row.get("ts") or row.get("created_at", ""),
+ "client_tg_id": row.get("client_tg_id", ""),
+ "manager_tg_id": row.get("manager_tg_id", ""),
+ "filled_by": row.get("filled_by", ""),
+ "layout": row.get("layout", ""),
+ "area_m2": row.get("area_m2", ""),
+ "ceiling_mm": row.get("ceiling_mm", ""),
+ "notes": row.get("notes", ""),
+ "status": row.get("status", ""),
+ })
+
+ # Сортируем по дате desc
+ out.sort(key=lambda x: x.get("created_at", ""), reverse=True)
+ return {"ok": True, "count": len(out), "measurements": out}
+
+
def _handle_test_ai() -> dict[str, Any]:
cfg = get_config()
res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",
diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js
index d3a21fb..49af5e7 100644
--- a/miniapp/assets/app.js
+++ b/miniapp/assets/app.js
@@ -179,8 +179,8 @@ function renderManagerHome(me) {
const quickActions = [
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
- { icon: "camera", title: "Новый замер", subtitle: "С фото", href: null },
- { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
+ { icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" },
+ { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
];
app.appendChild(el(`
Быстрые действия
`));
const grid = el(``);
@@ -370,6 +370,10 @@ async function init() {
Clients.mount(app);
return;
}
+ if (location.hash.startsWith("#/measure")) {
+ Measurements.mount(app);
+ return;
+ }
if (me.role === "manager") renderManager(me);
else renderClient(me);
} catch (e) {
@@ -383,6 +387,8 @@ function routeByHash() {
Podbor.mount(app);
} else if (location.hash.startsWith("#/clients")) {
Clients.mount(app);
+ } else if (location.hash.startsWith("#/measure")) {
+ Measurements.mount(app);
} else {
// Главный экран по роли
const me = window.__zovMe;
diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js
index 9397126..762baf6 100644
--- a/miniapp/assets/clients.js
+++ b/miniapp/assets/clients.js
@@ -153,6 +153,45 @@ const Clients = (function () {
leadsList.appendChild(item);
}
root.appendChild(leadsList);
+
+ // Замеры этого клиента (если есть)
+ try {
+ const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" });
+ const myMeasurements = (ms.measurements || []).filter(m => {
+ // Если client_tg_id зарегистрирован — фильтруем по нему
+ if (client.client_tg_id) return String(m.client_tg_id) === String(client.client_tg_id);
+ // Иначе — ищем имя клиента в notes (упрощённая логика для новых клиентов)
+ return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase());
+ });
+ if (myMeasurements.length) {
+ root.appendChild(el(`Замеры · ${myMeasurements.length}
`));
+ const mList = el(``);
+ for (const m of myMeasurements) {
+ const item = el(`
+
+
${formatDate(m.created_at)}
+
${escHtml(layoutLabel(m.layout))}
+
${m.area_m2 ? m.area_m2 + " м²" : "—"}
+
+
+ `);
+ mList.appendChild(item);
+ }
+ root.appendChild(mList);
+ }
+ } catch (e) {
+ // Игнорируем — секция замеров просто не покажется
+ }
+ }
+
+ function layoutLabel(key) {
+ return ({
+ linear: "Прямая",
+ l_shape: "Угловая Г",
+ u_shape: "П-образная",
+ island: "С островом",
+ peninsula: "Полуостров",
+ }[key]) || (key || "—");
}
/* ===================== Детали лида (re-render отчёта) ===================== */
@@ -245,6 +284,15 @@ const Clients = (function () {
return await res.json();
}
+ async function fetchMeasurements(filters = {}) {
+ if (!BACKEND_URL) throw new Error("BACKEND_URL не задан");
+ const res = await fetch(`${BACKEND_URL}/api/measurements`, {
+ method: "POST",
+ body: JSON.stringify({ initData: tg?.initData || "", ...filters }),
+ });
+ return await res.json();
+ }
+
function initial(name) {
return ((name || "?").trim()[0] || "?").toUpperCase();
}
diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js
new file mode 100644
index 0000000..02d8720
--- /dev/null
+++ b/miniapp/assets/measurements.js
@@ -0,0 +1,455 @@
+/* ============================================================
+ Замеры кухни — wizard для менеджера
+ ============================================================ */
+
+const Measurements = (function () {
+ const STORAGE_KEY = "zov-measurement-draft";
+ const STEPS = ["client", "layout", "size", "openings", "summary"];
+ const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"];
+
+ const LAYOUTS = [
+ { key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" },
+ { key: "l_shape", label: "Угловая Г", hint: "две стены, угол", pict: "layout_l_shape" },
+ { key: "u_shape", label: "П-образная", hint: "три стены", pict: "layout_u_shape" },
+ { key: "island", label: "С островом", hint: "линейная + блок", pict: "layout_island" },
+ { key: "peninsula", label: "Полуостров", hint: "Г + барная", pict: "layout_peninsula" },
+ ];
+
+ let state = loadState();
+ let root = null;
+ let currentStep = "client";
+
+ function loadState() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch (e) {}
+ return defaultState();
+ }
+
+ function defaultState() {
+ return {
+ client_name: "",
+ client_phone: "",
+ client_tg_id: "",
+ layout: "",
+ area_m2: "",
+ ceiling_mm: "",
+ walls: {}, // { wall1: 3200, wall2: 4100, ... } — мм
+ openings: {
+ window: "", // расположение окна
+ door: "", // расположение двери
+ },
+ notes: "",
+ };
+ }
+
+ function saveState() {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
+ }
+
+ function update(patch) {
+ state = { ...state, ...patch };
+ saveState();
+ }
+
+ function reset() {
+ state = defaultState();
+ saveState();
+ }
+
+ /* ===================== Mount + Render ===================== */
+
+ function mount(container) {
+ root = container;
+ document.body.classList.remove("has-bottom-nav");
+ const oldNav = document.getElementById("bottom-nav");
+ if (oldNav) oldNav.remove();
+ currentStep = "client";
+ render();
+ }
+
+ function go(step) {
+ if (!STEPS.includes(step)) return;
+ currentStep = step;
+ render();
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ haptic && haptic("impact");
+ }
+
+ function render() {
+ if (!root) return;
+ root.innerHTML = "";
+ root.appendChild(renderHeader());
+ root.appendChild(renderProgress());
+ const screen = el(``);
+ root.appendChild(screen);
+
+ switch (currentStep) {
+ case "client": screen.appendChild(renderClient()); break;
+ case "layout": screen.appendChild(renderLayout()); break;
+ case "size": screen.appendChild(renderSize()); break;
+ case "openings": screen.appendChild(renderOpenings()); break;
+ case "summary": screen.appendChild(renderSummary()); break;
+ }
+ }
+
+ function renderHeader() {
+ const h = el(`
+
+ `);
+ h.querySelector(".podbor-back").addEventListener("click", () => {
+ const idx = STEPS.indexOf(currentStep);
+ if (idx <= 0) {
+ location.hash = "";
+ location.reload();
+ } else {
+ go(STEPS[idx - 1]);
+ }
+ });
+ return h;
+ }
+
+ function renderProgress() {
+ const idx = STEPS.indexOf(currentStep);
+ const pct = Math.round(((idx + 1) / STEPS.length) * 100);
+ return el(`
+
+
+
+ ${STEP_LABELS[idx]}${idx + 1}/${STEPS.length}
+
+
+ `);
+ }
+
+ /* ===================== Шаг 1: Клиент ===================== */
+
+ function renderClient() {
+ const node = el(`
+
+ `);
+
+ bindInputs(node);
+
+ node.querySelector("#next").addEventListener("click", () => {
+ const name = (state.client_name || "").trim();
+ const phone = (state.client_phone || "").trim();
+ if (!name) {
+ node.querySelector("#nameError").textContent = "Укажите имя";
+ return;
+ }
+ // Используем нормализацию из podbor
+ if (phone && window.Podbor && typeof normalizePhoneShared === "function") {
+ // not exposed — поэтому минимальная локальная проверка
+ }
+ if (phone && phone.replace(/\D/g, "").length < 10) {
+ node.querySelector("#phoneError").textContent = "Слишком короткий номер";
+ return;
+ }
+ go("layout");
+ });
+ return node;
+ }
+
+ /* ===================== Шаг 2: Форма ===================== */
+
+ function renderLayout() {
+ const cur = state.layout || "";
+ const cards = LAYOUTS.map(o => {
+ const isOn = cur === o.key;
+ const pict = PODBOR_PICTS[o.pict] || "";
+ return `
+
+ `;
+ }).join("");
+
+ const node = el(`
+
+ Форма кухни
+ Как расположены гарнитуры?
+ ${cards}
+
+
+
+
+ `);
+ node.querySelectorAll(".wiz-card").forEach(card => {
+ card.addEventListener("click", () => {
+ update({ layout: card.dataset.val });
+ haptic && haptic("impact");
+ go("size");
+ });
+ });
+ node.querySelector("#back").addEventListener("click", () => go("client"));
+ return node;
+ }
+
+ /* ===================== Шаг 3: Размеры ===================== */
+
+ function renderSize() {
+ // По выбранной планировке — определяем сколько стен
+ const wallsCount = {
+ linear: 1, l_shape: 2, u_shape: 3, island: 1, peninsula: 2,
+ }[state.layout] || 1;
+
+ const wallInputs = [];
+ for (let i = 1; i <= wallsCount; i++) {
+ const v = (state.walls && state.walls[`wall${i}`]) || "";
+ const label = wallsCount === 1 ? "Длина стены, мм"
+ : `Стена ${i} (${i === 1 ? "основная" : "доп."}), мм`;
+ wallInputs.push(`
+
+
+
+ `);
+ }
+
+ const node = el(`
+
+ `);
+
+ bindInputs(node);
+ // Wall inputs — пишем в state.walls
+ node.querySelectorAll("[data-wall]").forEach(inp => {
+ inp.addEventListener("input", e => {
+ const w = { ...(state.walls || {}), [e.target.dataset.wall]: e.target.value };
+ update({ walls: w });
+ });
+ });
+
+ node.querySelector("#back").addEventListener("click", () => go("layout"));
+ node.querySelector("#next").addEventListener("click", () => go("openings"));
+ return node;
+ }
+
+ /* ===================== Шаг 4: Окна и двери ===================== */
+
+ function renderOpenings() {
+ const o = state.openings || {};
+ const node = el(`
+
+ Окна
и двери
+ Опиши расположение — где окно, откуда вход, есть ли коммуникации.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ bindInputs(node);
+ node.querySelectorAll("[data-open]").forEach(inp => {
+ inp.addEventListener("input", e => {
+ update({ openings: { ...(state.openings || {}), [e.target.dataset.open]: e.target.value } });
+ });
+ });
+ node.querySelector("#back").addEventListener("click", () => go("size"));
+ node.querySelector("#next").addEventListener("click", () => go("summary"));
+ return node;
+ }
+
+ /* ===================== Шаг 5: Готово + Submit ===================== */
+
+ function renderSummary() {
+ const layout = LAYOUTS.find(l => l.key === state.layout);
+ const wallsText = Object.entries(state.walls || {})
+ .map(([k, v]) => v ? `${k.replace("wall", "стена ")}: ${v} мм` : "")
+ .filter(Boolean).join(" · ");
+
+ const node = el(`
+
+ Готово
к сохранению
+ Проверьте и сохраните замер.
+
+
Клиент${escHtml(state.client_name)}
+ ${state.client_phone ? `
Телефон${escHtml(state.client_phone)}
` : ""}
+
Форма${layout?.label || "—"}
+ ${wallsText ? `
Стены${escHtml(wallsText)}
` : ""}
+ ${state.area_m2 ? `
Площадь${escHtml(state.area_m2)} м²
` : ""}
+ ${state.ceiling_mm ? `
Потолок${escHtml(state.ceiling_mm)} мм
` : ""}
+ ${(state.openings || {}).window ? `
Окно${escHtml(state.openings.window)}
` : ""}
+ ${(state.openings || {}).door ? `
Дверь${escHtml(state.openings.door)}
` : ""}
+ ${state.notes ? `
Заметки${escHtml(state.notes)}
` : ""}
+
+
+
+
+
+
+
+
+
+ `);
+ node.querySelector("#back").addEventListener("click", () => go("openings"));
+ node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
+ return node;
+ }
+
+ async function onSubmit(node) {
+ const btn = node.querySelector("#submitBtn");
+ const result = node.querySelector("#submitResult");
+ btn.disabled = true;
+ btn.innerHTML = ' сохраняем...';
+ result.innerHTML = "";
+
+ if (!BACKEND_URL) {
+ result.innerHTML = `BACKEND_URL не настроен.
`;
+ btn.disabled = false;
+ btn.textContent = "Сохранить замер";
+ return;
+ }
+
+ const measurement = {
+ layout: state.layout,
+ area_m2: state.area_m2,
+ ceiling_mm: state.ceiling_mm,
+ walls: state.walls,
+ openings: state.openings,
+ infra: {},
+ niches: {},
+ photos: [],
+ notes: state.notes,
+ // Контакт клиента — заносим в заметки если он не зарегистрирован в системе
+ client_name: state.client_name,
+ client_phone: state.client_phone,
+ };
+
+ try {
+ const res = await fetch(`${BACKEND_URL}/api/measurement`, {
+ method: "POST",
+ body: JSON.stringify({
+ initData: tg?.initData || "",
+ measurement,
+ }),
+ });
+ const data = await res.json();
+ if (data.error) {
+ result.innerHTML = `Ошибка: ${data.error}
`;
+ } else {
+ result.innerHTML = `
+
+
${ICONS.check}
+
+
Замер сохранён
+
ID #${(data.id || "").slice(0, 6)}
+
+
+
+
+
+
+ `;
+ haptic && haptic("success");
+ reset(); // сбрасываем форму для следующего замера
+ node.querySelector("#newOne")?.addEventListener("click", () => { mount(root); });
+ node.querySelector("#toHome")?.addEventListener("click", () => {
+ location.hash = "";
+ location.reload();
+ });
+ }
+ } catch (e) {
+ result.innerHTML = `Сеть: ${e.message}
`;
+ }
+ btn.disabled = false;
+ btn.textContent = "Сохранить ещё раз";
+ }
+
+ /* ===================== Helpers ===================== */
+
+ function bindInputs(node) {
+ node.querySelectorAll("[data-bind]").forEach(inp => {
+ inp.addEventListener("input", e => {
+ update({ [e.target.dataset.bind]: e.target.value });
+ });
+ });
+ }
+
+ function escHtml(s) {
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+ function escAttr(s) { return escHtml(s); }
+
+ return { mount, reset };
+})();
diff --git a/miniapp/assets/podbor.picts.js b/miniapp/assets/podbor.picts.js
index fe8ca49..11b81e8 100644
--- a/miniapp/assets/podbor.picts.js
+++ b/miniapp/assets/podbor.picts.js
@@ -921,6 +921,117 @@ const PODBOR_PICTS = {
`,
+ /* ===== Планировка кухни · 5 типов (top-down вид) ===== */
+
+ layout_linear: `
+
+ `,
+
+ layout_l_shape: `
+
+ `,
+
+ layout_u_shape: `
+
+ `,
+
+ layout_island: `
+
+ `,
+
+ layout_peninsula: `
+
+ `,
+
washer_install_freestanding: `