mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:04:48 +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.
|
// На входе: подписанный 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();
|
||||||
|
|||||||
@ -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
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;
|
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;
|
||||||
|
}
|
||||||
|
/* ─────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user