feat: история заказов #/c/orders — таймлайн подборов и сборок

- 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 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-18 15:19:20 +03:00
parent e71ac3a5a8
commit b75f24e4d7
6 changed files with 358 additions and 4 deletions

25
memory/business_rules.md Normal file
View File

@ -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` → менеджер ввёл вручную

View File

@ -1,4 +1,4 @@
// ЗОВ MiniApp — главный скрипт. v20260518k // ЗОВ MiniApp — главный скрипт. v20260518l
// На входе: подписанный initData от Telegram. // На входе: подписанный initData от Telegram.
// Ходим на backend → получаем профиль (роль, статус) → рендерим меню. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню.
@ -1719,6 +1719,9 @@ function routeByHash() {
} else { } else {
app.innerHTML = `<div class="error">Модуль не загружен</div>`; app.innerHTML = `<div class="error">Модуль не загружен</div>`;
} }
} else if (location.hash === "#/c/orders") {
if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app);
else init();
} else if (location.hash === "#/c/selfmeasure") { } else if (location.hash === "#/c/selfmeasure") {
if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app); if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app);
else init(); else init();

View File

@ -184,7 +184,8 @@ const CabinetScreen = (function () {
${renderManagerBlock(me.manager)} ${renderManagerBlock(me.manager)}
${renderProposalsBlock(proposalsData.proposals || [])} ${renderProposalsBlock(proposalsData.proposals || [])}
${renderAssembliesBlock(assembliesData.assemblies || [])} ${renderAssembliesBlock(assembliesData.assemblies || [])}
<div class="block" style="margin:12px 16px 0;"> <div class="block" style="margin:12px 16px 0;display:flex;flex-direction:column;gap:8px;">
<button class="btn-secondary" data-href="#/c/orders" style="width:100%;">📋 История заказов</button>
<button class="btn-secondary" data-href="#/c/selfmeasure" style="width:100%;">📐 Самозамер кухни</button> <button class="btn-secondary" data-href="#/c/selfmeasure" style="width:100%;">📐 Самозамер кухни</button>
</div> </div>
<div style="height:32px;"></div> <div style="height:32px;"></div>

233
miniapp/assets/orders.js Normal file
View File

@ -0,0 +1,233 @@
/* ============================================================
История заказов #/c/orders
Единый таймлайн: подборы + сборки клиента.
============================================================ */
const OrdersScreen = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 `
<div class="orders-item${hasLink ? " orders-item--link" : ""}"
${hasLink ? `data-href="${escHtml(item.href)}"` : ""}>
<div class="orders-item-left">
<div class="orders-item-icon">${item.icon}</div>
<div class="orders-item-line"></div>
</div>
<div class="orders-item-body">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div>
<div class="orders-item-type">${escHtml(item.typeLabel)}</div>
${item.title ? `<div class="orders-item-title">${escHtml(item.title)}</div>` : ""}
</div>
<div style="flex-shrink:0;text-align:right;">
<div style="${statusStyle}">${escHtml(item.statusText)}</div>
${dateStr ? `<div style="font-size:11px;color:var(--muted);margin-top:2px;">${escHtml(dateStr)}</div>` : ""}
</div>
</div>
${item.subtitle ? `<div class="orders-item-subtitle">${escHtml(item.subtitle)}</div>` : ""}
${item.calUrl ? `<a href="${escHtml(item.calUrl)}" target="_blank" style="font-size:12px;color:var(--accent);text-decoration:none;">📅 Посмотреть в календаре</a>` : ""}
</div>
</div>`;
}
/* ---- Маппинг подбора в элемент таймлайна -------------------- */
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 `
<div style="display:flex;flex-direction:column;align-items:center;padding:60px 24px;text-align:center;">
<div style="font-size:48px;margin-bottom:16px;">📋</div>
<div style="font-size:16px;font-weight:600;color:var(--ink);margin-bottom:8px;">Заказов пока нет</div>
<div style="font-size:13px;color:var(--muted);line-height:1.5;max-width:260px;">
Когда менеджер создаст подбор или запланирует сборку всё появится здесь.
</div>
<button class="btn-primary" data-href="#/c/proposal" style="margin-top:24px;min-width:200px;">🛒 Запросить подбор</button>
</div>`;
}
/* ── 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 = `
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">История заказов</div>
<div style="width:36px"></div>
`;
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 = `<div class="loader-inline"><div class="spinner"></div></div>`;
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 = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
}
}
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 };
})();

View File

@ -1478,3 +1478,94 @@ html[data-variant="d"] .section-head .label {
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); 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;
}
/* ─────────────────────────────────────────────────────────────── */

View File

@ -12,7 +12,7 @@
<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=Archivo:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Manrope:wght@400;500;600;700&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=Archivo:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Manrope:wght@400;500;600;700&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=20260517j"> <link rel="stylesheet" href="assets/styles.css?v=20260518l">
<link rel="stylesheet" href="assets/podbor.css?v=20260517j"> <link rel="stylesheet" href="assets/podbor.css?v=20260517j">
</head> </head>
<body> <body>
@ -49,6 +49,7 @@
<script src="assets/inbox.js?v=20260518i"></script> <script src="assets/inbox.js?v=20260518i"></script>
<script src="assets/cabinet.js?v=20260518j"></script> <script src="assets/cabinet.js?v=20260518j"></script>
<script src="assets/selfmeasure.js?v=20260518k"></script> <script src="assets/selfmeasure.js?v=20260518k"></script>
<script src="assets/app.js?v=20260518k"></script> <script src="assets/orders.js?v=20260518l"></script>
<script src="assets/app.js?v=20260518l"></script>
</body> </body>
</html> </html>