From b75f24e4d7fd601bdea80c4cdafacc42135483f2 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Mon, 18 May 2026 15:19:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20#/c/order?= =?UTF-8?q?s=20=E2=80=94=20=D1=82=D0=B0=D0=B9=D0=BC=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B1=D0=BE=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orders.js: единый таймлайн proposal_list + assembly_list, сортировка по дате - cabinet.js: кнопка «📋 История заказов» → #/c/orders - app.js: маршрут #/c/orders, версия l - styles.css: классы orders-timeline, .block, .block-head, .assembly-card - memory/business_rules.md: замер 2500₽ + 40₽/км за КАД СПб Co-Authored-By: Claude Sonnet 4.6 --- memory/business_rules.md | 25 ++++ miniapp/assets/app.js | 5 +- miniapp/assets/cabinet.js | 3 +- miniapp/assets/orders.js | 233 ++++++++++++++++++++++++++++++++++++++ miniapp/assets/styles.css | 91 +++++++++++++++ miniapp/index.html | 5 +- 6 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 memory/business_rules.md create mode 100644 miniapp/assets/orders.js diff --git a/memory/business_rules.md b/memory/business_rules.md new file mode 100644 index 0000000..3870464 --- /dev/null +++ b/memory/business_rules.md @@ -0,0 +1,25 @@ +# Бизнес-правила ЗОВ / ИП Васильев Р.Г. + +## Стоимость замера + +| Условие | Цена | +|---|---| +| Стандартный выезд (в черте КАД СПб) | **2 500 ₽** | +| За каждый км за пределами КАД СПб | **+ 40 ₽/км** | + +> Дистанция считается от КАД до адреса клиента (в одну сторону). + +## Сборка + +- Стоимость сборки согласовывается отдельно по каждому заказу. +- Срок: фиксируется в поле `scheduled_at` сборки. + +## Активный период клиента + +- `ACTIVE_PERIOD_DAYS` = 90 дней +- `GRACE_PERIOD_DAYS` = 14 дней + +## Источники самозамера + +- `source=self_measure` в листе Measurements → клиент сам заполнил через мастер #/c/selfmeasure +- `source=manager` → менеджер ввёл вручную diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 07668ed..847cbfa 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -1,4 +1,4 @@ -// ЗОВ MiniApp — главный скрипт. v20260518k +// ЗОВ MiniApp — главный скрипт. v20260518l // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. @@ -1719,6 +1719,9 @@ function routeByHash() { } else { app.innerHTML = `
Модуль не загружен
`; } + } else if (location.hash === "#/c/orders") { + if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app); + else init(); } else if (location.hash === "#/c/selfmeasure") { if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app); else init(); diff --git a/miniapp/assets/cabinet.js b/miniapp/assets/cabinet.js index dc621b5..ab04400 100644 --- a/miniapp/assets/cabinet.js +++ b/miniapp/assets/cabinet.js @@ -184,7 +184,8 @@ const CabinetScreen = (function () { ${renderManagerBlock(me.manager)} ${renderProposalsBlock(proposalsData.proposals || [])} ${renderAssembliesBlock(assembliesData.assemblies || [])} -
+
+
diff --git a/miniapp/assets/orders.js b/miniapp/assets/orders.js new file mode 100644 index 0000000..1b4420c --- /dev/null +++ b/miniapp/assets/orders.js @@ -0,0 +1,233 @@ +/* ============================================================ + История заказов — #/c/orders + Единый таймлайн: подборы + сборки клиента. + ============================================================ */ + +const OrdersScreen = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + function fmtDate(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }); + } catch { return iso.slice(0, 10); } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }), + }); + if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + /* ---- Статусы подборов ---------------------------------------- */ + const PROPOSAL_LABELS = { + brief: { icon: "📝", text: "Анкета заполнена", color: "#8e8e8e" }, + draft: { icon: "⏳", text: "Готовим подбор", color: "#F39C12" }, + sent: { icon: "📨", text: "Подбор отправлен", color: "#2980B9" }, + reviewed: { icon: "✅", text: "Вы просмотрели", color: "#27AE60" }, + done: { icon: "🎉", text: "Завершён", color: "#16a085" }, + archived: { icon: "📦", text: "В архиве", color: "#bdc3c7" }, + }; + + /* ---- Статусы сборок ------------------------------------------ */ + const ASSEMBLY_LABELS = { + created: { icon: "🆕", text: "Создана", color: "#8e8e8e" }, + scheduled: { icon: "📅", text: "Запланирована", color: "#2980B9" }, + in_progress: { icon: "🔨", text: "В процессе", color: "#F39C12" }, + done: { icon: "✅", text: "Завершена", color: "#27AE60" }, + cancelled: { icon: "❌", text: "Отменена", color: "#C0392B" }, + }; + + /* ---- Один элемент таймлайна ---------------------------------- */ + function renderItem(item) { + const statusStyle = `color:${item.statusColor};font-size:12px;font-weight:500;`; + const hasLink = !!item.href; + const dateStr = fmtDate(item.date); + + return ` + `; + } + + /* ---- Маппинг подбора в элемент таймлайна -------------------- */ + function proposalToItem(p) { + const sl = PROPOSAL_LABELS[p.status] || { icon: "📋", text: p.status, color: "#8e8e8e" }; + const partsArr = []; + if (p.n_categories) partsArr.push(`${p.n_categories} категор.`); + if (p.n_variants) partsArr.push(`${p.n_variants} вар.`); + const subtitle = partsArr.join(" · "); + const date = p.sent_at || p.reviewed_at || p.created_at; + return { + type: "proposal", + date, + icon: sl.icon, + typeLabel: "Подбор кухни", + title: null, + subtitle: subtitle || null, + statusText: sl.text, + statusColor: sl.color, + href: p.id ? `#/c/proposal/${encodeURIComponent(p.id)}` : null, + calUrl: null, + }; + } + + /* ---- Маппинг сборки в элемент таймлайна --------------------- */ + function assemblyToItem(a) { + const sl = ASSEMBLY_LABELS[a.status] || { icon: "🔧", text: a.status, color: "#8e8e8e" }; + const date = a.scheduled_at || a.ts; + return { + type: "assembly", + date, + icon: sl.icon, + typeLabel: "Сборка кухни", + title: a.address || null, + subtitle: a.scope_of_work || null, + statusText: sl.text, + statusColor: sl.color, + href: null, + calUrl: a.gcal_event_url || null, + }; + } + + /* ---- Пустое состояние --------------------------------------- */ + function renderEmpty() { + return ` +
+
📋
+
Заказов пока нет
+
+ Когда менеджер создаст подбор или запланирует сборку — всё появится здесь. +
+ +
`; + } + + /* ── mount ─────────────────────────────────────────────────── */ + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + // Header + const h = document.createElement("header"); + h.className = "podbor-header"; + h.innerHTML = ` + +
История заказов
+
+ `; + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + container.appendChild(h); + + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.innerHTML = `
`; + container.appendChild(screen); + + try { + const [proposalsData, assembliesData] = await Promise.all([ + _api("proposal_list").catch(() => ({ proposals: [] })), + _api("assembly_list").catch(() => ({ assemblies: [] })), + ]); + + const proposals = (proposalsData.proposals || []).map(proposalToItem); + const assemblies = (assembliesData.assemblies || []).map(assemblyToItem); + + const all = [...proposals, ...assemblies].sort((a, b) => { + const da = a.date || ""; + const db = b.date || ""; + return db.localeCompare(da); + }); + + screen.innerHTML = ""; + + if (!all.length) { + screen.innerHTML = renderEmpty(); + screen.querySelectorAll("[data-href]").forEach(el => { + el.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = el.dataset.href; + }); + }); + return; + } + + // Счётчик + const countDiv = document.createElement("div"); + countDiv.style.cssText = "padding:12px 16px 4px;font-size:13px;color:var(--muted);"; + countDiv.textContent = `Всего: ${all.length} ${_declinate(all.length, ["запись", "записи", "записей"])}`; + screen.appendChild(countDiv); + + // Таймлайн + const timeline = document.createElement("div"); + timeline.className = "orders-timeline"; + timeline.innerHTML = all.map(renderItem).join(""); + screen.appendChild(timeline); + + const spacer = document.createElement("div"); + spacer.style.height = "32px"; + screen.appendChild(spacer); + + // Клики + screen.querySelectorAll("[data-href]").forEach(el => { + el.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = el.dataset.href; + }); + }); + + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + } + + function _declinate(n, forms) { + const abs = Math.abs(n) % 100; + const r = abs % 10; + if (abs > 10 && abs < 20) return forms[2]; + if (r > 1 && r < 5) return forms[1]; + if (r === 1) return forms[0]; + return forms[2]; + } + + return { mount }; +})(); diff --git a/miniapp/assets/styles.css b/miniapp/assets/styles.css index 3cccd22..f9d59b4 100644 --- a/miniapp/assets/styles.css +++ b/miniapp/assets/styles.css @@ -1478,3 +1478,94 @@ html[data-variant="d"] .section-head .label { text-transform: uppercase; color: var(--muted); } + +/* ── История заказов — таймлайн ─────────────────────────────── */ +.orders-timeline { + padding: 8px 0 0; +} +.orders-item { + display: flex; + gap: 0; + padding: 0 16px 0 12px; +} +.orders-item--link { + cursor: pointer; +} +.orders-item--link:active { + opacity: 0.7; +} +.orders-item-left { + display: flex; + flex-direction: column; + align-items: center; + width: 36px; + flex-shrink: 0; +} +.orders-item-icon { + font-size: 20px; + line-height: 1; + padding: 4px 0; + z-index: 1; + background: transparent; +} +.orders-item-line { + width: 2px; + flex: 1; + min-height: 16px; + background: var(--border, #e8e8e8); + margin: 2px 0; +} +.orders-item:last-child .orders-item-line { + display: none; +} +.orders-item-body { + flex: 1; + padding: 4px 0 20px 4px; +} +.orders-item-type { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 2px; +} +.orders-item-title { + font-size: 14px; + font-weight: 600; + color: var(--ink); + margin: 2px 0; +} +.orders-item-subtitle { + font-size: 12px; + color: var(--muted); + margin-top: 4px; + line-height: 1.4; +} + +/* cabinet / orders shared blocks */ +.block { + background: var(--surface, #fff); + border: 1px solid var(--border, #e8e8e8); + border-radius: 12px; + padding: 14px 14px 10px; +} +.block-head { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + margin-bottom: 8px; +} +.assembly-card { + border: 1px solid var(--border, #e8e8e8); + border-radius: 10px; + padding: 10px 12px; + background: var(--surface, #fff); +} +.btn-sm { + font-size: 13px; + padding: 7px 14px; +} +/* ─────────────────────────────────────────────────────────────── */ diff --git a/miniapp/index.html b/miniapp/index.html index 8768361..f17c5aa 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,7 +12,7 @@ - + @@ -49,6 +49,7 @@ - + +