zov-tech/miniapp/assets/proposals.js
wasrusgen 4abd7b2ecd feat: proposal cycle — client brief + manager editor + voting
Backend:
- proposals.py: new module with full proposal cycle
  (brief → draft → sent → reviewed → done),
  Google Sheets «Proposals» tab, Telegram notifications
- main.py: import proposals_mod, 9 new /api/proposal_* routes
  added to both dispatch map and native /api/* handlers

Frontend:
- proposals.js: self-contained Proposals module
  · Client: brief form (6 items + budget + notes),
    waiting screen, proposal view with / per variant,
    overall comment + submit
  · Manager: empty state → create, editor with categories,
    add-variant form, send button, client votes/feedback view
- clients.js: «Подбор техники» button now opens proposals
  editor page (#/clients/client/{key}/proposals); inline
  Proposals.mountManager() section added to client card;
  new renderClientProposalsPage() route handler
- app.js: #/c/proposal route for client side; client home
  «Подобрать технику» menu item activated (was «скоро»)
- podbor.css: ~350 lines of Proposals UI styles
- index.html: proposals.js added, cache version → 20260516e

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 09:28:36 +03:00

820 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
Подбор техники — цикл согласования (Proposals)
Клиент: brief → просмотр вариантов → голосование
Менеджер: создание → добавление вариантов → отправка
============================================================ */
const Proposals = (function () {
// ── Internal helpers ──────────────────────────────────────
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escAttr(s) { return escHtml(s); }
function authBody() {
return { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
}
async function apiFetch(path, extra = {}) {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST",
body: JSON.stringify({ ...authBody(), ...extra }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}
// ── Constants ─────────────────────────────────────────────
const CAT_LABELS = {
hob: "Варочная панель",
oven: "Духовой шкаф",
dishwasher: "Посудомойка",
hood: "Вытяжка",
fridge: "Холодильник",
microwave: "Микроволновка",
other: "Другое",
};
const STATUS_LABELS = {
brief: "Анкета принята",
draft: "Подборка готовится",
sent: "Ожидает вашего ответа",
reviewed: "Ответ отправлен",
done: "Завершено",
};
const STATUS_LABELS_MGR = {
brief: "📋 Анкета клиента",
draft: "✏️ Черновик",
sent: "📨 Отправлено клиенту",
reviewed: "📬 Клиент ответил",
done: "✅ Завершено",
};
const MANAGER_CATEGORIES = [
{ key: "hob", label: "Варочная панель" },
{ key: "oven", label: "Духовой шкаф" },
{ key: "dishwasher", label: "Посудомойка" },
{ key: "hood", label: "Вытяжка" },
{ key: "fridge", label: "Холодильник" },
{ key: "microwave", label: "Микроволновка" },
{ key: "other", label: "Другое" },
];
// ── Plural helper ─────────────────────────────────────────
function pluralVariants(n) {
if (n % 10 === 1 && n % 100 !== 11) return "вариант";
if ([2,3,4].includes(n % 10) && ![12,13,14].includes(n % 100)) return "варианта";
return "вариантов";
}
// ── Radio chips ───────────────────────────────────────────
function radioChips(name, opts, selected) {
return opts.map(o => {
const [val, lbl] = o.split(":");
return `<button class="prop-chip ${val === selected ? "on" : ""}" data-name="${escAttr(name)}" data-val="${escAttr(val)}" type="button">${escHtml(lbl)}</button>`;
}).join("");
}
function setupRadioChips(container) {
container.querySelectorAll(".prop-chip").forEach(btn => {
btn.addEventListener("click", () => {
const name = btn.dataset.name;
container.querySelectorAll(`.prop-chip[data-name="${name}"]`).forEach(b => b.classList.remove("on"));
btn.classList.add("on");
haptic && haptic("selection");
});
});
}
function getRadio(container, name) {
const active = container.querySelector(`.prop-chip[data-name="${name}"].on`);
return active ? active.dataset.val : null;
}
// ── Brief summary renderer (for manager view) ─────────────
function renderBriefSummary(brief) {
const hobMap = { induction: "Индукция", gas: "Газ", electric: "Электро", none: "Не нужна" };
const hoodMap = { builtin: "Встройка", dome: "Купол", none: "Не нужна" };
const rows = [];
if (brief.hob) rows.push(["Варочная", hobMap[brief.hob] || brief.hob]);
if (brief.oven === "yes") rows.push(["Духовка", "Нужна"]);
if (brief.dishwasher && brief.dishwasher !== "none") rows.push(["Посудомойка", brief.dishwasher + " см"]);
if (brief.hood && brief.hood !== "none") rows.push(["Вытяжка", hoodMap[brief.hood] || brief.hood]);
if (brief.fridge === "yes") rows.push(["Холодильник", "Нужен"]);
if (brief.microwave === "yes") rows.push(["Микроволновка", "Нужна"]);
if (brief.budget) rows.push(["Бюджет", Number(brief.budget).toLocaleString("ru-RU") + " ₽"]);
if (brief.notes) rows.push(["Пожелания", brief.notes]);
if (!rows.length) return `<p class="prop-muted">Анкета пустая</p>`;
return `<div class="brief-rows">${rows.map(([k, v]) =>
`<div class="brief-row"><span class="brief-key">${escHtml(k)}</span><span class="brief-val">${escHtml(String(v))}</span></div>`
).join("")}</div>`;
}
// ── Votes summary for manager ─────────────────────────────
function renderVotesSummary(positions) {
const yes = [], no = [];
for (const cat of (positions || [])) {
for (const v of (cat.variants || [])) {
const label = `${cat.label || CAT_LABELS[cat.category] || cat.category}: ${v.model || "—"}`;
if (v.client_vote === "yes") yes.push(label);
else if (v.client_vote === "no") no.push(label);
}
}
if (!yes.length && !no.length) return `<p class="prop-muted">Клиент ещё не голосовал</p>`;
let html = "";
if (yes.length) html += `<div class="vote-group"><div class="vote-group-head yes">✅ Нравится (${yes.length})</div>${yes.map(l => `<div class="vote-item">${escHtml(l)}</div>`).join("")}</div>`;
if (no.length) html += `<div class="vote-group"><div class="vote-group-head no">❌ Не подходит (${no.length})</div>${no.map(l => `<div class="vote-item">${escHtml(l)}</div>`).join("")}</div>`;
return html;
}
// ── Source badge ──────────────────────────────────────────
const SOURCE_LABELS = { dns: "DNS", wb: "WB", ozon: "Ozon", citilink: "Ситилинк", yamarket: "Яндекс" };
function sourceBadge(src) {
if (!src) return "";
return `<span class="prop-source-badge ${escAttr(src)}">${escHtml(SOURCE_LABELS[src] || src.toUpperCase())}</span>`;
}
// ══════════════════════════════════════════════════════════
// CLIENT FLOW
// ══════════════════════════════════════════════════════════
async function mountClient(container) {
container.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
try {
const data = await apiFetch("proposal_list");
const proposals = (data.proposals || []);
const active = proposals.find(p =>
["brief", "draft", "sent", "reviewed"].includes(p.status)
);
if (!active) {
showClientBriefForm(container, null);
return;
}
if (active.status === "brief" || active.status === "draft") {
showClientWaiting(container, active);
return;
}
// sent or reviewed — load full detail
const detail = await apiFetch("proposal_detail", { proposal_id: active.id });
if (detail.ok) {
showClientProposal(container, detail.proposal);
} else {
showClientWaiting(container, active);
}
} catch (e) {
container.innerHTML = `<div class="error">Не удалось загрузить: ${escHtml(e.message)}</div>`;
}
}
// ── Client: brief form ────────────────────────────────────
function showClientBriefForm(container, prefill) {
const p = prefill || {};
container.innerHTML = "";
container.appendChild(el(`
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${(typeof ICONS !== "undefined" && ICONS.arrow_left) || ""}</button>
<div class="podbor-title">ПОДБОР ТЕХНИКИ</div>
<div style="width:28px"></div>
</header>
`));
container.querySelector(".podbor-back").addEventListener("click", () => {
history.back();
});
const form = el(`
<section class="podbor-step">
<h2 class="display-title">Расскажите,<br><span class="accent">что нужно?</span></h2>
<p class="lede">Ответьте — менеджер подберёт технику под ваш бюджет и кухню.</p>
<div class="prop-field-group">
<div class="prop-field-label">Варочная панель</div>
<div class="prop-chips-row">
${radioChips("hob", ["none:Не нужна","induction:Индукция","gas:Газ","electric:Электро"], p.hob || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Духовой шкаф</div>
<div class="prop-chips-row">
${radioChips("oven", ["no:Не нужен","yes:Нужен"], p.oven || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Посудомойка</div>
<div class="prop-chips-row">
${radioChips("dw", ["none:Не нужна","45:45 см","60:60 см"], p.dishwasher || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Вытяжка</div>
<div class="prop-chips-row">
${radioChips("hood", ["none:Не нужна","builtin:Встройка","dome:Купол"], p.hood || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Холодильник</div>
<div class="prop-chips-row">
${radioChips("fridge_need", ["no:Не нужен","yes:Нужен"], p.fridge || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Микроволновка</div>
<div class="prop-chips-row">
${radioChips("micro_need", ["no:Не нужна","yes:Нужна"], p.microwave || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Бюджет на технику</div>
<label class="field">
<input type="number" id="bf_budget" placeholder="например 120 000" inputmode="numeric"
min="0" step="1000" value="${escAttr(String(p.budget || ""))}">
<span class="field-hint">Необязательно — только ориентир для менеджера</span>
</label>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Пожелания</div>
<label class="field">
<textarea id="bf_notes" rows="3" placeholder="Любим Bosch, хотелось бы паровой режим, потолок низкий…">${escHtml(p.notes || "")}</textarea>
</label>
</div>
<div class="podbor-cta-row" style="margin-top:24px;">
<button class="btn-primary" id="bf_submit" type="button">Отправить менеджеру</button>
</div>
<div id="bf_result" class="submit-result"></div>
</section>
`);
container.appendChild(form);
setupRadioChips(container);
container.querySelector("#bf_submit").addEventListener("click", async () => {
const btn = container.querySelector("#bf_submit");
const result = container.querySelector("#bf_result");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Отправляем…`;
try {
const data = await apiFetch("proposal_brief", {
hob: getRadio(container, "hob") || "none",
oven: getRadio(container, "oven") || "no",
dishwasher: getRadio(container, "dw") || "none",
hood: getRadio(container, "hood") || "none",
fridge: getRadio(container, "fridge_need") || "no",
microwave: getRadio(container, "micro_need") || "no",
budget: container.querySelector("#bf_budget")?.value || "",
notes: container.querySelector("#bf_notes")?.value || "",
});
if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Отправить менеджеру";
return;
}
haptic && haptic("success");
showClientWaiting(container, { status: "brief" });
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Отправить менеджеру";
}
});
}
// ── Client: waiting screen ────────────────────────────────
function showClientWaiting(container, proposal) {
container.innerHTML = "";
container.appendChild(el(`
<div class="prop-waiting">
<div class="prop-waiting-icon">📋</div>
<h2 class="prop-waiting-title">Анкета принята!</h2>
<p class="prop-waiting-text">
Менеджер подбирает варианты техники.<br>
Как только подборка будет готова — придёт уведомление в бот.
</p>
<div class="prop-status-badge ${escAttr(proposal?.status || "")}">${escHtml(STATUS_LABELS[proposal?.status] || "В работе")}</div>
<button class="btn-secondary" id="editBriefBtn" type="button" style="margin-top:20px;max-width:240px;">
Изменить анкету
</button>
</div>
`));
container.querySelector("#editBriefBtn")?.addEventListener("click", () => {
showClientBriefForm(container, null);
});
}
// ── Client: proposal view (voting) ───────────────────────
function showClientProposal(container, proposal) {
container.innerHTML = "";
const positions = proposal.positions || [];
const isReviewed = proposal.status === "reviewed";
container.appendChild(el(`
<header class="podbor-header">
<div style="width:28px"></div>
<div class="podbor-title">ПОДБОР ТЕХНИКИ</div>
<span class="prop-status-chip ${escAttr(proposal.status)}">${escHtml(STATUS_LABELS[proposal.status] || proposal.status)}</span>
</header>
`));
if (!positions.length) {
container.appendChild(el(`<div class="empty">Вариантов пока нет.</div>`));
return;
}
const catsWrap = el(`<div class="prop-cats"></div>`);
for (const cat of positions) {
catsWrap.appendChild(renderClientCategoryBlock(cat, proposal.id, isReviewed));
}
container.appendChild(catsWrap);
if (!isReviewed) {
const submitSection = el(`
<section class="podbor-step" style="margin-top:24px;">
<h3 class="display-title" style="font-size:20px;">Оставьте<br><span class="accent">комментарий</span></h3>
<p class="lede">Нажмите ✅/❌ на каждый вариант и напишите, что понравилось — или нет.</p>
<label class="field">
<textarea id="cl_comment" rows="3" placeholder="Нравится вариант 1, хотелось бы посмотреть ещё что-нибудь в этом бюджете…"></textarea>
</label>
<div class="podbor-cta-row" style="margin-top:12px;">
<button class="btn-primary" id="cl_submit" type="button">Отправить ответ менеджеру</button>
</div>
<div id="cl_result" class="submit-result"></div>
</section>
`);
container.appendChild(submitSection);
container.querySelector("#cl_submit")?.addEventListener("click", async () => {
const btn = container.querySelector("#cl_submit");
const result = container.querySelector("#cl_result");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Отправляем…`;
try {
const data = await apiFetch("proposal_client_submit", {
proposal_id: proposal.id,
comment: container.querySelector("#cl_comment")?.value || "",
});
if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
return;
}
haptic && haptic("success");
result.innerHTML = `
<div class="success">
<div class="success-icon">✓</div>
<div>
<div class="success-title">Ответ отправлен!</div>
<div class="success-sub">Менеджер получил уведомление</div>
</div>
</div>`;
submitSection.querySelector("textarea, .podbor-cta-row")?.remove();
const statusChip = container.querySelector(".prop-status-chip");
if (statusChip) { statusChip.textContent = STATUS_LABELS.reviewed; statusChip.className = "prop-status-chip reviewed"; }
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
}
});
} else {
container.appendChild(el(`
<div class="prop-reviewed-note">
✅ Вы уже отправили ответ менеджеру. Ожидайте подтверждения.
${proposal.client_comment ? `<div class="prop-reviewed-comment">«${escHtml(proposal.client_comment)}»</div>` : ""}
</div>
`));
}
}
// ── Client: category block ────────────────────────────────
function renderClientCategoryBlock(cat, proposalId, isReviewed) {
const label = cat.label || CAT_LABELS[cat.category] || cat.category;
const variants = cat.variants || [];
const block = el(`
<div class="prop-cat-block">
<div class="prop-cat-head">
<span class="prop-cat-label">${escHtml(label)}</span>
<span class="prop-cat-count">${variants.length} ${pluralVariants(variants.length)}</span>
</div>
<div class="prop-variants-list"></div>
</div>
`);
const varList = block.querySelector(".prop-variants-list");
variants.forEach(v => varList.appendChild(renderClientVariantCard(v, proposalId, cat.category, isReviewed)));
return block;
}
// ── Client: variant card ──────────────────────────────────
function renderClientVariantCard(v, proposalId, category, isReviewed) {
const priceStr = v.price ? `${Number(v.price).toLocaleString("ru-RU")}` : "";
const vote = v.client_vote;
const card = el(`
<div class="prop-variant-card ${vote === "yes" ? "voted-yes" : vote === "no" ? "voted-no" : ""}">
${v.image_url
? `<div class="prop-variant-img"><img src="${escAttr(v.image_url)}" alt="" loading="lazy"></div>`
: `<div class="prop-variant-img placeholder"></div>`}
<div class="prop-variant-body">
<div class="prop-variant-name">${escHtml(v.model || "—")}</div>
${priceStr ? `<div class="prop-variant-price">${escHtml(priceStr)}</div>` : ""}
${sourceBadge(v.source)}
${v.manager_comment ? `<div class="prop-variant-mgr-note">💬 ${escHtml(v.manager_comment)}</div>` : ""}
${v.url ? `<a class="prop-variant-link" href="${escAttr(v.url)}" target="_blank" rel="noopener noreferrer">Смотреть →</a>` : ""}
${isReviewed
? `<div class="prop-vote-result">${vote === "yes" ? "✅ Выбрано" : vote === "no" ? "❌ Отклонено" : "— Без оценки"}</div>`
: `<div class="prop-vote-row">
<button class="prop-vote-btn yes ${vote === "yes" ? "active" : ""}" data-vote="yes" type="button">✅ Нравится</button>
<button class="prop-vote-btn no ${vote === "no" ? "active" : ""}" data-vote="no" type="button">❌ Не то</button>
</div>`
}
</div>
</div>
`);
if (!isReviewed) {
card.querySelectorAll(".prop-vote-btn").forEach(btn => {
btn.addEventListener("click", async () => {
const newVote = btn.dataset.vote;
const finalVote = (v.client_vote === newVote) ? null : newVote;
try {
const data = await apiFetch("proposal_vote", {
proposal_id: proposalId, category, variant_id: v.id, vote: finalVote,
});
if (data.ok) {
haptic && haptic("impact");
v.client_vote = finalVote;
card.className = `prop-variant-card ${finalVote === "yes" ? "voted-yes" : finalVote === "no" ? "voted-no" : ""}`;
card.querySelectorAll(".prop-vote-btn").forEach(b => b.classList.remove("active"));
if (finalVote) card.querySelector(`.prop-vote-btn[data-vote="${finalVote}"]`)?.classList.add("active");
}
} catch (_) {}
});
});
}
return card;
}
// ══════════════════════════════════════════════════════════
// MANAGER FLOW
// ══════════════════════════════════════════════════════════
async function mountManager(container, clientKey, clientTgId) {
container.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
try {
const data = await apiFetch("proposal_list");
const proposals = (data.proposals || []).filter(p => p.client_key === clientKey);
const active = proposals.find(p =>
["brief", "draft", "sent", "reviewed"].includes(p.status)
);
if (!active) {
renderManagerEmpty(container, clientKey, clientTgId);
return;
}
const detail = await apiFetch("proposal_detail", { proposal_id: active.id });
if (detail.ok) {
renderManagerEditor(container, detail.proposal, clientKey);
} else {
renderManagerEmpty(container, clientKey, clientTgId);
}
} catch (e) {
container.innerHTML = `<div class="error">Ошибка: ${escHtml(e.message)}</div>`;
}
}
// ── Manager: empty state ──────────────────────────────────
function renderManagerEmpty(container, clientKey, clientTgId) {
container.innerHTML = "";
container.appendChild(el(`
<div class="prop-mgr-empty">
<p class="lede">Подборки для этого клиента ещё нет.</p>
<button class="btn-primary" id="mgrCreate" type="button" style="max-width:240px;">
Создать подборку
</button>
<div id="mgrCreateResult" class="submit-result"></div>
</div>
`));
container.querySelector("#mgrCreate")?.addEventListener("click", async () => {
const btn = container.querySelector("#mgrCreate");
const result = container.querySelector("#mgrCreateResult");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Создаём…`;
try {
const data = await apiFetch("proposal_create", {
client_key: clientKey,
client_tg_id: clientTgId || "",
});
if (data.error) {
result.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Создать подборку";
return;
}
await mountManager(container, clientKey, clientTgId);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Создать подборку";
}
});
}
// ── Manager: main editor ──────────────────────────────────
function renderManagerEditor(container, proposal, clientKey) {
container.innerHTML = "";
const positions = proposal.positions || [];
const canEdit = ["brief", "draft", "reviewed"].includes(proposal.status);
const canSend = proposal.status === "draft" && positions.some(p => p.variants?.length);
const statusLbl = STATUS_LABELS_MGR[proposal.status] || proposal.status;
// Status bar
container.appendChild(el(`
<div class="prop-mgr-status-bar">
<span class="prop-mgr-status-label ${escAttr(proposal.status)}">${escHtml(statusLbl)}</span>
${proposal.sent_at ? `<span class="prop-mgr-ts">Отправлено: ${escHtml(proposal.sent_at.slice(0,10))}</span>` : ""}
${proposal.reviewed_at ? `<span class="prop-mgr-ts">Ответ: ${escHtml(proposal.reviewed_at.slice(0,10))}</span>` : ""}
</div>
`));
// Client feedback (reviewed state)
if (proposal.status === "reviewed") {
const fb = el(`
<div class="prop-client-feedback">
<div class="prop-feedback-head">📬 Ответ клиента</div>
${renderVotesSummary(positions)}
${proposal.client_comment
? `<div class="prop-client-comment-block">💬 ${escHtml(proposal.client_comment)}</div>`
: ""}
</div>
`);
container.appendChild(fb);
}
// Brief summary (collapsible)
if (proposal.brief && Object.keys(proposal.brief).some(k => proposal.brief[k] && proposal.brief[k] !== "none" && proposal.brief[k] !== "no")) {
const det = el(`
<details class="prop-brief-details">
<summary class="prop-brief-toggle">📋 Анкета клиента</summary>
<div class="prop-brief-content">${renderBriefSummary(proposal.brief)}</div>
</details>
`);
container.appendChild(det);
}
// Categories
if (positions.length) {
const catsWrap = el(`<div class="prop-mgr-cats"></div>`);
positions.forEach(cat => {
catsWrap.appendChild(renderManagerCategoryBlock(cat, proposal, canEdit, () =>
mountManager(container, clientKey, proposal.client_tg_id)
));
});
container.appendChild(catsWrap);
} else {
container.appendChild(el(`
<div class="prop-mgr-hint">Категорий пока нет. Добавьте первую позицию ниже.</div>
`));
}
// Add variant form
if (canEdit) {
container.appendChild(
renderAddVariantForm(proposal.id, clientKey, proposal.client_tg_id, container)
);
}
// Send button
if (canSend) {
const sendWrap = el(`
<div class="podbor-cta-row" style="margin-top:20px;">
<button class="btn-primary" id="mgrSend" type="button">📨 Отправить клиенту</button>
</div>
<div id="mgrSendResult" class="submit-result"></div>
`);
container.appendChild(sendWrap);
container.querySelector("#mgrSend")?.addEventListener("click", async () => {
const btn = container.querySelector("#mgrSend");
const result = container.querySelector("#mgrSendResult");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Отправляем…`;
try {
const data = await apiFetch("proposal_send", { proposal_id: proposal.id });
if (data.error) {
result.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "📨 Отправить клиенту";
return;
}
haptic && haptic("success");
await mountManager(container, clientKey, proposal.client_tg_id);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "📨 Отправить клиенту";
}
});
}
}
// ── Manager: category block ───────────────────────────────
function renderManagerCategoryBlock(cat, proposal, canEdit, onRefresh) {
const label = cat.label || CAT_LABELS[cat.category] || cat.category;
const variants = cat.variants || [];
const block = el(`
<div class="prop-mgr-cat">
<div class="prop-mgr-cat-head">
<span class="prop-cat-label">${escHtml(label)}</span>
<span class="prop-cat-count">${variants.length} ${pluralVariants(variants.length)}</span>
${canEdit ? `<button class="prop-cat-del-btn" type="button" title="Удалить категорию">✕</button>` : ""}
</div>
<div class="prop-mgr-variants"></div>
</div>
`);
if (canEdit) {
block.querySelector(".prop-cat-del-btn")?.addEventListener("click", async () => {
if (!confirm(`Удалить «${label}» со всеми вариантами?`)) return;
try {
await apiFetch("proposal_remove_variant", { proposal_id: proposal.id, category: cat.category });
await onRefresh();
} catch (_) {}
});
}
const varWrap = block.querySelector(".prop-mgr-variants");
variants.forEach(v => varWrap.appendChild(
renderManagerVariantRow(v, proposal, cat.category, canEdit, onRefresh)
));
return block;
}
// ── Manager: variant row ──────────────────────────────────
function renderManagerVariantRow(v, proposal, category, canEdit, onRefresh) {
const priceStr = v.price ? `${Number(v.price).toLocaleString("ru-RU")}` : "";
const voteIcon = v.client_vote === "yes" ? " ✅" : v.client_vote === "no" ? " ❌" : "";
const row = el(`
<div class="prop-mgr-variant-row">
<div class="prop-mgr-variant-name">${escHtml(v.model || "—")}${voteIcon}</div>
<div class="prop-mgr-variant-meta">
${priceStr ? `<span class="prop-mgr-price">${escHtml(priceStr)}</span>` : ""}
${sourceBadge(v.source)}
</div>
${v.manager_comment ? `<div class="prop-mgr-variant-comment">${escHtml(v.manager_comment)}</div>` : ""}
${v.url ? `<a class="prop-variant-link" href="${escAttr(v.url)}" target="_blank" rel="noopener noreferrer">Открыть →</a>` : ""}
${canEdit ? `<button class="prop-variant-del-btn" type="button">Удалить</button>` : ""}
</div>
`);
if (canEdit) {
row.querySelector(".prop-variant-del-btn")?.addEventListener("click", async () => {
try {
await apiFetch("proposal_remove_variant", {
proposal_id: proposal.id, category, variant_id: v.id,
});
await onRefresh();
} catch (_) {}
});
}
return row;
}
// ── Manager: add variant form ─────────────────────────────
function renderAddVariantForm(proposalId, clientKey, clientTgId, container) {
const wrap = el(`
<details class="prop-add-form">
<summary class="prop-add-summary"> Добавить позицию</summary>
<div class="prop-add-body">
<div class="prop-field-group">
<div class="prop-field-label">Категория</div>
<select id="av_cat" class="prop-select">
${MANAGER_CATEGORIES.map(c =>
`<option value="${escAttr(c.key)}">${escHtml(c.label)}</option>`
).join("")}
</select>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Модель *</div>
<input type="text" id="av_model" placeholder="Bosch PXX875D67E" class="prop-input">
</div>
<div class="prop-field-group">
<div class="prop-field-label">Ссылка на товар</div>
<input type="url" id="av_url" placeholder="https://dns-shop.ru/…" class="prop-input">
</div>
<div class="prop-field-group two-col-group">
<div>
<div class="prop-field-label">Цена, ₽</div>
<input type="number" id="av_price" placeholder="45 990" inputmode="numeric" class="prop-input">
</div>
<div>
<div class="prop-field-label">Магазин</div>
<select id="av_source" class="prop-select">
<option value="">—</option>
<option value="dns">DNS</option>
<option value="wb">Wildberries</option>
<option value="ozon">Ozon</option>
<option value="citilink">Ситилинк</option>
<option value="yamarket">Яндекс Маркет</option>
</select>
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Комментарий</div>
<textarea id="av_mgr_comment" rows="2" class="prop-input"
placeholder="Топ-модель, 5 зон, авто-выкл, подходит под ширину 60 см…"></textarea>
</div>
<div class="podbor-cta-row">
<button class="btn-primary" id="av_save" type="button">Добавить</button>
</div>
<div id="av_result" class="submit-result"></div>
</div>
</details>
`);
wrap.querySelector("#av_save")?.addEventListener("click", async () => {
const btn = wrap.querySelector("#av_save");
const result = wrap.querySelector("#av_result");
const model = (wrap.querySelector("#av_model")?.value || "").trim();
if (!model) {
result.innerHTML = `<div class="error">Укажите название модели</div>`;
return;
}
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Добавляем…`;
const catKey = wrap.querySelector("#av_cat")?.value || "";
const catLabel = MANAGER_CATEGORIES.find(c => c.key === catKey)?.label || catKey;
try {
const data = await apiFetch("proposal_upsert_variant", {
proposal_id: proposalId,
category: catKey,
category_label: catLabel,
variant: {
model,
url: (wrap.querySelector("#av_url")?.value || "").trim(),
price: wrap.querySelector("#av_price")?.value || "",
source: wrap.querySelector("#av_source")?.value || "",
manager_comment: (wrap.querySelector("#av_mgr_comment")?.value || "").trim(),
},
});
if (data.error) {
result.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Добавить";
return;
}
haptic && haptic("success");
// Clear fields
["av_model", "av_url", "av_price", "av_mgr_comment"].forEach(id => {
const el2 = wrap.querySelector(`#${id}`);
if (el2) el2.value = "";
});
result.innerHTML = `<div class="success"><div class="success-icon">✓</div><div><div class="success-title">Добавлено!</div></div></div>`;
btn.disabled = false; btn.textContent = "Добавить";
// Reload manager view
await mountManager(container, clientKey, clientTgId);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Добавить";
}
});
return wrap;
}
// ══════════════════════════════════════════════════════════
// PUBLIC API
// ══════════════════════════════════════════════════════════
return { mountClient, mountManager };
})();