feat: broadcast to clients via Telegram

- New Deal field "Контакт клиента" (Telegram chat_id / @username).
- РОП gets "📣 Рассылка клиентам" button → modal with template,
  recipient selection, preview, batch send.
- Template variables: {company}, {manager}, {stage}, {status}, {contact}.
- Per-recipient result feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
askhat 2026-05-19 14:38:25 +05:00
parent a858a53531
commit 64cadc0ece
2 changed files with 121 additions and 0 deletions

View File

@ -152,6 +152,7 @@ function openDealModal(id){
form.tag.value = d.tag || "";
form.currentStatus.value = d.currentStatus || "";
form.managerAway.checked = !!d.managerAway;
form.clientContact.value = d.clientContact || "";
$("#modal-title").textContent = "Сделка: " + d.company;
$("#delete-deal").hidden = false;
if(d.history && d.history.length){
@ -201,6 +202,7 @@ function bindEvents(){
deal.stage = fd.get("stage");
deal.tag = fd.get("tag") || "";
deal.managerAway = fd.get("managerAway") === "on";
deal.clientContact = (fd.get("clientContact") || "").trim();
if(statusChanged && statusText){
deal.currentStatus = statusText;
@ -282,6 +284,17 @@ function bindEvents(){
window.toast(r.ok ? "Сообщение отправлено ✓" : ("Ошибка: " + (r.description || "—")), r.ok ? "ok" : "error");
});
$("#open-broadcast").addEventListener("click", openBroadcast);
$("#bc-close").addEventListener("click", () => $("#bc-modal").hidden = true);
$("#bc-modal").addEventListener("click", e => { if(e.target.id === "bc-modal") $("#bc-modal").hidden = true; });
$("#bc-select-all").addEventListener("change", e => {
$$("#bc-recipients input[type=checkbox]").forEach(cb => cb.checked = e.target.checked);
updateBcCount();
});
$("#bc-recipients").addEventListener("change", updateBcCount);
$("#bc-preview").addEventListener("click", previewBroadcast);
$("#bc-send").addEventListener("click", sendBroadcast);
$("#send-summary").addEventListener("click", async () => {
const cfg = window.TelegramCfg.load();
if(!cfg.token || !cfg.chatRop){
@ -294,6 +307,85 @@ function bindEvents(){
});
}
function renderTemplate(tpl, deal){
return tpl
.replace(/\{company\}/g, deal.company || "")
.replace(/\{manager\}/g, deal.manager || "")
.replace(/\{stage\}/g, deal.stage || "")
.replace(/\{status\}/g, deal.currentStatus || "—")
.replace(/\{contact\}/g, deal.clientContact || "");
}
function openBroadcast(){
$("#bc-results").innerHTML = "";
$("#bc-select-all").checked = false;
const all = window.Store.list().filter(d => window.Store.inControl(d));
const list = $("#bc-recipients");
if(all.length === 0){
list.innerHTML = `<div style="color:#999;text-align:center;padding:20px">Нет активных сделок на контроле</div>`;
} else {
list.innerHTML = all.map(d => {
const has = !!(d.clientContact && d.clientContact.trim());
return `<label style="display:flex;gap:8px;align-items:center;padding:4px 0;${has?'':'opacity:.5'}">
<input type="checkbox" data-id="${d.id}" ${has?'':'disabled'}>
<span><b>${escapeHtml(d.company)}</b> — ${escapeHtml(d.manager)} · ${escapeHtml(d.stage)}
${has ? `<span style="color:var(--muted);font-size:12px"> → ${escapeHtml(d.clientContact)}</span>` : `<span style="color:var(--danger);font-size:12px"> (нет контакта)</span>`}
</span>
</label>`;
}).join("");
}
updateBcCount();
$("#bc-modal").hidden = false;
}
function updateBcCount(){
const n = $$("#bc-recipients input[type=checkbox]:checked").length;
$("#bc-count").textContent = n > 0 ? `Выбрано: ${n}` : "";
}
function previewBroadcast(){
const checked = $$("#bc-recipients input[type=checkbox]:checked");
if(checked.length === 0){ window.toast("Выберите хотя бы одного получателя", "error"); return; }
const tpl = $("#bc-template").value;
const first = window.Store.get(checked[0].dataset.id);
const rendered = renderTemplate(tpl, first);
$("#bc-results").innerHTML = `<b>Превью (${escapeHtml(first.company)}):</b><pre style="white-space:pre-wrap;background:#f5f5f5;padding:8px;border-radius:4px;margin-top:4px">${escapeHtml(rendered)}</pre>`;
}
async function sendBroadcast(){
const cfg = window.TelegramCfg.load();
if(!cfg.token){
window.toast("Сначала настройте Telegram (Bot Token)", "error");
return;
}
const checked = $$("#bc-recipients input[type=checkbox]:checked");
if(checked.length === 0){ window.toast("Выберите получателей", "error"); return; }
const tpl = $("#bc-template").value.trim();
if(!tpl){ window.toast("Введите текст шаблона", "error"); return; }
if(!confirm(`Отправить ${checked.length} сообщений?`)) return;
$("#bc-send").disabled = true;
const results = [];
for(const cb of checked){
const deal = window.Store.get(cb.dataset.id);
const text = renderTemplate(tpl, deal);
const r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: deal.clientContact, text, parse_mode: "HTML" })
}).then(r => r.json()).catch(e => ({ ok: false, description: e.message }));
results.push({ deal, ok: r.ok, err: r.description || "" });
}
$("#bc-send").disabled = false;
const ok = results.filter(r => r.ok).length;
const fail = results.length - ok;
$("#bc-results").innerHTML = `<b>Результат: ${ok} успешно, ${fail} с ошибкой</b><ul style="margin:4px 0;padding-left:20px">` +
results.map(r => `<li>${r.ok?'✅':'❌'} ${escapeHtml(r.deal.company)} ${r.err?`— <span style="color:var(--danger)">${escapeHtml(r.err)}</span>`:''}</li>`).join("") +
`</ul>`;
window.toast(`Отправлено: ${ok}/${results.length}`, fail === 0 ? "ok" : "error");
}
function escapeHtml(s){
return (s ?? "").toString()
.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")

View File

@ -108,6 +108,7 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
<select id="rop-filter"><option value="">— все —</option></select>
</label>
<button id="send-summary" type="button">📨 Отправить сводку в Telegram</button>
<button id="open-broadcast" type="button">📣 Рассылка клиентам</button>
</div>
<h3>Просрочки на сегодня</h3>
<pre id="rop-summary" class="summary-block"></pre>
@ -158,6 +159,9 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
<label>Текущий статус (Лог дня) — мин. 15 символов
<textarea name="currentStatus" rows="3" placeholder="Например: ЛПР взял паузу до завтра, согласовывает бюджет"></textarea>
</label>
<label>Контакт клиента (Telegram chat_id или @username)
<input name="clientContact" placeholder="@ivanov или 123456789">
</label>
<label class="checkbox">
<input type="checkbox" name="managerAway"> Менеджер временно отсутствует
</label>
@ -192,6 +196,31 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
</div>
</div>
<!-- модалка рассылки -->
<div id="bc-modal" class="modal" hidden>
<div class="modal-content" style="max-width:760px">
<button class="modal-close" id="bc-close" type="button">✕</button>
<h3>📣 Рассылка клиентам</h3>
<p class="hint">Сообщение шлётся через Telegram-бот клиентам, у которых заполнено поле «Контакт клиента». Клиент должен сначала написать боту /start, иначе Telegram отклонит сообщение.</p>
<label style="display:block;margin-bottom:8px">Шаблон сообщения (HTML)
<textarea id="bc-template" rows="5" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-family:inherit;font-size:13px">Здравствуйте! По сделке <b>{company}</b> на этапе «{stage}»: {status}
С уважением, {manager}</textarea>
</label>
<p class="hint">Переменные: <code>{company}</code>, <code>{manager}</code>, <code>{stage}</code>, <code>{status}</code>, <code>{contact}</code></p>
<div style="display:flex;gap:12px;align-items:center;margin:8px 0">
<label><input type="checkbox" id="bc-select-all"> Выбрать всех с контактом</label>
<span id="bc-count" style="color:var(--muted);font-size:13px"></span>
</div>
<div id="bc-recipients" style="max-height:300px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px"></div>
<div id="bc-results" style="margin-top:12px;font-size:13px"></div>
<div class="form-actions">
<button type="button" id="bc-preview">👁 Превью</button>
<button type="button" id="bc-send" class="primary">Отправить</button>
</div>
</div>
</div>
<div id="toast-host"></div>
<script src="./assets/holidays.js"></script>