mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
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:
parent
e71ac3a5a8
commit
b75f24e4d7
25
memory/business_rules.md
Normal file
25
memory/business_rules.md
Normal 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` → менеджер ввёл вручную
|
||||
@ -1,4 +1,4 @@
|
||||
// ЗОВ MiniApp — главный скрипт. v20260518k
|
||||
// ЗОВ MiniApp — главный скрипт. v20260518l
|
||||
// На входе: подписанный initData от Telegram.
|
||||
// Ходим на backend → получаем профиль (роль, статус) → рендерим меню.
|
||||
|
||||
@ -1719,6 +1719,9 @@ function routeByHash() {
|
||||
} else {
|
||||
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") {
|
||||
if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app);
|
||||
else init();
|
||||
|
||||
@ -184,7 +184,8 @@ const CabinetScreen = (function () {
|
||||
${renderManagerBlock(me.manager)}
|
||||
${renderProposalsBlock(proposalsData.proposals || [])}
|
||||
${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>
|
||||
</div>
|
||||
<div style="height:32px;"></div>
|
||||
|
||||
233
miniapp/assets/orders.js
Normal file
233
miniapp/assets/orders.js
Normal file
@ -0,0 +1,233 @@
|
||||
/* ============================================================
|
||||
История заказов — #/c/orders
|
||||
Единый таймлайн: подборы + сборки клиента.
|
||||
============================================================ */
|
||||
|
||||
const OrdersScreen = (function () {
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").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 `
|
||||
<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 };
|
||||
})();
|
||||
@ -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;
|
||||
}
|
||||
/* ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<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">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@ -49,6 +49,7 @@
|
||||
<script src="assets/inbox.js?v=20260518i"></script>
|
||||
<script src="assets/cabinet.js?v=20260518j"></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>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user