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:
parent
a858a53531
commit
64cadc0ece
@ -152,6 +152,7 @@ function openDealModal(id){
|
|||||||
form.tag.value = d.tag || "";
|
form.tag.value = d.tag || "";
|
||||||
form.currentStatus.value = d.currentStatus || "";
|
form.currentStatus.value = d.currentStatus || "";
|
||||||
form.managerAway.checked = !!d.managerAway;
|
form.managerAway.checked = !!d.managerAway;
|
||||||
|
form.clientContact.value = d.clientContact || "";
|
||||||
$("#modal-title").textContent = "Сделка: " + d.company;
|
$("#modal-title").textContent = "Сделка: " + d.company;
|
||||||
$("#delete-deal").hidden = false;
|
$("#delete-deal").hidden = false;
|
||||||
if(d.history && d.history.length){
|
if(d.history && d.history.length){
|
||||||
@ -201,6 +202,7 @@ function bindEvents(){
|
|||||||
deal.stage = fd.get("stage");
|
deal.stage = fd.get("stage");
|
||||||
deal.tag = fd.get("tag") || "";
|
deal.tag = fd.get("tag") || "";
|
||||||
deal.managerAway = fd.get("managerAway") === "on";
|
deal.managerAway = fd.get("managerAway") === "on";
|
||||||
|
deal.clientContact = (fd.get("clientContact") || "").trim();
|
||||||
|
|
||||||
if(statusChanged && statusText){
|
if(statusChanged && statusText){
|
||||||
deal.currentStatus = statusText;
|
deal.currentStatus = statusText;
|
||||||
@ -282,6 +284,17 @@ function bindEvents(){
|
|||||||
window.toast(r.ok ? "Сообщение отправлено ✓" : ("Ошибка: " + (r.description || "—")), r.ok ? "ok" : "error");
|
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 () => {
|
$("#send-summary").addEventListener("click", async () => {
|
||||||
const cfg = window.TelegramCfg.load();
|
const cfg = window.TelegramCfg.load();
|
||||||
if(!cfg.token || !cfg.chatRop){
|
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){
|
function escapeHtml(s){
|
||||||
return (s ?? "").toString()
|
return (s ?? "").toString()
|
||||||
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
||||||
|
|||||||
29
index.html
29
index.html
@ -108,6 +108,7 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
|
|||||||
<select id="rop-filter"><option value="">— все —</option></select>
|
<select id="rop-filter"><option value="">— все —</option></select>
|
||||||
</label>
|
</label>
|
||||||
<button id="send-summary" type="button">📨 Отправить сводку в Telegram</button>
|
<button id="send-summary" type="button">📨 Отправить сводку в Telegram</button>
|
||||||
|
<button id="open-broadcast" type="button">📣 Рассылка клиентам</button>
|
||||||
</div>
|
</div>
|
||||||
<h3>Просрочки на сегодня</h3>
|
<h3>Просрочки на сегодня</h3>
|
||||||
<pre id="rop-summary" class="summary-block"></pre>
|
<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 символов
|
<label>Текущий статус (Лог дня) — мин. 15 символов
|
||||||
<textarea name="currentStatus" rows="3" placeholder="Например: ЛПР взял паузу до завтра, согласовывает бюджет"></textarea>
|
<textarea name="currentStatus" rows="3" placeholder="Например: ЛПР взял паузу до завтра, согласовывает бюджет"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Контакт клиента (Telegram chat_id или @username)
|
||||||
|
<input name="clientContact" placeholder="@ivanov или 123456789">
|
||||||
|
</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="managerAway"> Менеджер временно отсутствует
|
<input type="checkbox" name="managerAway"> Менеджер временно отсутствует
|
||||||
</label>
|
</label>
|
||||||
@ -192,6 +196,31 @@ td.status-cell textarea:focus{background:#fff;outline:2px solid var(--primary)}
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="toast-host"></div>
|
||||||
|
|
||||||
<script src="./assets/holidays.js"></script>
|
<script src="./assets/holidays.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user