Files

928 lines
32 KiB
JavaScript

const state = {
user: 'admin',
role: 'admin',
token: null,
authSource: 'local',
fullName: null,
dashboard: null,
leads: [],
deals: [],
selectedDealId: '',
workspaceById: {},
logLines: [],
};
const SESSION_STORAGE_KEY = 'cc_session';
const STAGE_OPTIONS = [
'new_qualified_lead',
'warm_lead',
'hot_lead',
'enrichment_required',
'active_text_communication',
'active_voice_communication',
'waiting_customer_reply',
'offer_preparing',
'offer_sent',
'conditions_negotiation',
'counterparty_data_requested',
'counterparty_data_received',
'document_preparing',
'document_sent',
'document_confirmed',
'invoice_preparing',
'invoice_sent',
'payment_expected',
'partially_paid',
'paid',
'payment_overdue',
'won',
'lost',
'postponed',
'follow_up_scheduled',
'transferred_to_execution',
'transferred_to_support',
];
const BOARD_GROUPS = [
{ key: 'inbound', title: 'Вход', note: 'Квалифицированный lead intake и дообогащение.', stages: ['new_qualified_lead', 'warm_lead', 'hot_lead', 'enrichment_required'] },
{ key: 'communication', title: 'Коммуникация', note: 'Text/voice сессии, вопросы и follow-up.', stages: ['active_text_communication', 'active_voice_communication', 'waiting_customer_reply'] },
{ key: 'commercial', title: 'Коммерция', note: 'Оффер, согласование условий, next step.', stages: ['offer_preparing', 'offer_sent', 'conditions_negotiation'] },
{ key: 'paperwork', title: 'Оформление', note: 'Реквизиты, документы, подтверждение.', stages: ['counterparty_data_requested', 'counterparty_data_received', 'document_preparing', 'document_sent', 'document_confirmed'] },
{ key: 'finance', title: 'Финансы', note: 'Счет, оплата, просрочка.', stages: ['invoice_preparing', 'invoice_sent', 'payment_expected', 'partially_paid', 'paid', 'payment_overdue'] },
{ key: 'outcome', title: 'Закрытие', note: 'Win/loss/post-sale handoff.', stages: ['won', 'lost', 'postponed', 'follow_up_scheduled', 'transferred_to_execution', 'transferred_to_support'] },
];
const STAGE_LABELS = {
new_qualified_lead: 'Новый квалифицированный лид',
warm_lead: 'Теплый лид',
hot_lead: 'Горячий лид',
enrichment_required: 'Нужно дообогащение',
active_text_communication: 'Текстовая коммуникация',
active_voice_communication: 'Голосовая коммуникация',
waiting_customer_reply: 'Ждем ответ клиента',
offer_preparing: 'Готовим оффер',
offer_sent: 'Оффер отправлен',
conditions_negotiation: 'Согласуем условия',
counterparty_data_requested: 'Запрос реквизитов',
counterparty_data_received: 'Реквизиты получены',
document_preparing: 'Готовим документ',
document_sent: 'Документ отправлен',
document_confirmed: 'Документ подтвержден',
invoice_preparing: 'Готовим счет',
invoice_sent: 'Счет отправлен',
payment_expected: 'Ждем оплату',
partially_paid: 'Частичная оплата',
paid: 'Оплачено',
payment_overdue: 'Просрочка оплаты',
won: 'Успешно',
lost: 'Потеряно',
postponed: 'Отложено',
follow_up_scheduled: 'Follow-up',
transferred_to_execution: 'Передано в исполнение',
transferred_to_support: 'Передано человеку',
};
const $ = (id) => document.getElementById(id);
function log(message, payload) {
const ts = new Date().toLocaleTimeString();
const suffix = payload ? ` ${JSON.stringify(payload)}` : '';
state.logLines.unshift(`[${ts}] ${message}${suffix}`);
state.logLines = state.logLines.slice(0, 14);
$('salesLog').textContent = state.logLines.join('\n');
}
function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'admin';
state.role = $('sessionRole').value || 'admin';
}
function persistSession() {
window.localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
access_token: state.token,
user: state.user,
role: state.role,
auth_source: state.authSource,
full_name: state.fullName,
}),
);
}
function clearStoredSession() {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
function restoreStoredSession() {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
if (!payload?.access_token) {
clearStoredSession();
return false;
}
state.token = payload.access_token;
state.user = payload.user || state.user;
state.role = payload.role || state.role;
state.authSource = payload.auth_source || 'local';
state.fullName = payload.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = state.role;
$('authPanel').style.display = 'none';
updateSessionInfo();
updateProfile();
return true;
} catch {
clearStoredSession();
return false;
}
}
function updateSessionInfo(extra = '') {
$('sessionInfo').textContent = state.token
? `Токен активен | роль: ${state.role}${extra ? ` | ${extra}` : ''}`
: 'Токен: отсутствует';
}
function updateProfile() {
$('profileName').textContent = state.fullName || state.user || 'Экран продаж';
$('profileRole').textContent = `Роль: ${state.role || 'unknown'}`;
}
async function api(path, options = {}) {
syncSessionFromInputs();
const headers = {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/sales/${path}`, { ...options, headers });
let data;
try {
data = await response.json();
} catch {
data = { error: 'Сервис вернул не JSON' };
}
if (!response.ok) {
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function login() {
syncSessionFromInputs();
try {
const data = await fetch('/proxy/auth/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
},
body: JSON.stringify({
username: state.user,
password: $('sessionPassword').value,
}),
}).then((response) => response.json());
state.token = data.access_token;
state.role = data.role;
state.authSource = data.auth_source || 'local';
state.fullName = data.full_name || null;
$('authPanel').style.display = 'none';
updateSessionInfo();
updateProfile();
persistSession();
log('Логин выполнен', { user: state.user, role: state.role });
await refreshAll();
} catch (error) {
updateSessionInfo(`ошибка: ${error.message}`);
log('Ошибка входа', { error: error.message });
}
}
function logout() {
clearStoredSession();
window.location.href = '/';
}
async function checkGateway() {
try {
const data = await fetch('/health').then((response) => response.json());
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
} catch {
$('gatewayStatus').textContent = 'Шлюз: недоступен';
}
}
function stageLabel(stageId) {
return STAGE_LABELS[stageId] || stageId || 'Без этапа';
}
function boardGroupsWithItems() {
const query = ($('salesSearch').value || '').trim().toLowerCase();
return BOARD_GROUPS.map((group) => {
const items = state.deals.filter((deal) => {
const inGroup = group.stages.includes(deal.stage_id);
if (!inGroup) {
return false;
}
if (!query) {
return true;
}
return [
deal.title,
deal.need_summary,
deal.current_channel,
deal.customer_id,
].some((value) => String(value || '').toLowerCase().includes(query));
});
return { ...group, items };
});
}
function salesCard(deal) {
const activeClass = deal.deal_id === state.selectedDealId ? 'active' : '';
const amount = Number(deal.final_amount || deal.estimated_amount || 0);
return `
<button class="sales-card ${activeClass}" type="button" data-deal-id="${deal.deal_id}">
<div class="sales-card-top">
<div class="sales-card-title">${deal.title}</div>
<div class="sales-card-priority">P${deal.priority}</div>
</div>
<div class="sales-card-body">${deal.need_summary || 'Потребность еще не уточнена.'}</div>
<div class="sales-card-meta">
<span class="sales-chip">${stageLabel(deal.stage_id)}</span>
<span class="sales-chip">${deal.current_channel || 'канал не задан'}</span>
<span class="sales-chip">${amount.toLocaleString('ru-RU')} ${deal.currency || 'KZT'}</span>
</div>
</button>
`;
}
function renderBoard() {
$('salesBoard').innerHTML = boardGroupsWithItems()
.map(
(group) => `
<section class="sales-column">
<div class="sales-column-head">
<div class="sales-column-title">${group.title}</div>
<div class="sales-column-count">${group.items.length}</div>
</div>
<div class="sales-column-note">${group.note}</div>
<div class="sales-card-list">
${group.items.length ? group.items.map((deal) => salesCard(deal)).join('') : '<div class="sales-workspace-empty">Пусто.</div>'}
</div>
</section>
`,
)
.join('');
}
function kpiCard(label, value, note) {
return `
<article class="sales-kpi-card">
<div class="sales-kpi-label">${label}</div>
<div class="sales-kpi-value">${value}</div>
<div class="sales-kpi-note">${note}</div>
</article>
`;
}
function renderDashboard() {
const data = state.dashboard;
if (!data) {
$('salesDashboard').innerHTML = '';
return;
}
$('salesDashboard').innerHTML = [
kpiCard('Лиды', Number(data.leads_total || 0), 'Сколько квалифицированных лидов уже в CRM'),
kpiCard('Активные сделки', Number(data.deals_active || 0), 'Открытые кейсы в работе'),
kpiCard('Выиграно', Number(data.deals_won || 0), 'Закрыто с оплатой'),
kpiCard('Просрочка', Number(data.overdue_invoices || 0), 'Счета, которые зависли'),
kpiCard('Ожидаем оплату', `${Number(data.payment_expected || 0).toLocaleString('ru-RU')} KZT`, 'Потенциальный cash-in'),
kpiCard('Получено', `${Number(data.payment_received || 0).toLocaleString('ru-RU')} KZT`, 'Фактические оплаты'),
kpiCard('Voice сессии', Number(data.voice_sessions || 0), 'Голосовой контур продаж'),
kpiCard('Text сессии', Number(data.text_sessions || 0), 'Текстовый контур продаж'),
kpiCard('Switch channel', Number(data.channel_switches || 0), 'Переходы между voice/text'),
kpiCard('Эскалации', Number(data.human_escalations || 0), 'Передачи живому менеджеру'),
].join('');
}
function renderStageOptions() {
$('dealStageSelect').innerHTML = STAGE_OPTIONS.map((value) => `<option value="${value}">${stageLabel(value)}</option>`).join('');
}
function workspaceMetric(label, value) {
return `
<div class="sales-metric">
<div class="sales-metric-label">${label}</div>
<div class="sales-metric-value">${value}</div>
</div>
`;
}
function kvItem(label, value) {
return `
<div class="sales-kv-item">
<div class="sales-kv-label">${label}</div>
<div class="sales-kv-value">${value || '—'}</div>
</div>
`;
}
function listRows(items, titleKey, bodyKey) {
if (!items?.length) {
return '<div class="sales-workspace-empty">Пока пусто.</div>';
}
return `<div class="sales-list">${items
.map(
(item) => `
<div class="sales-list-row">
<div>
<strong>${item[titleKey]}</strong>
<span>${item[bodyKey] || ''}</span>
</div>
<div>
<span>${item.updated_at || item.created_at || item.started_at || ''}</span>
</div>
</div>
`,
)
.join('')}</div>`;
}
function renderWorkspace() {
if (!state.selectedDealId) {
$('dealWorkspace').innerHTML = 'Выберите карточку на воронке, чтобы увидеть подробности.';
$('dealMetrics').innerHTML = '';
$('dealTitle').textContent = 'Выберите сделку';
$('dealSubtitle').textContent = 'Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.';
$('dealStageBadge').textContent = 'Нет данных';
$('telegramThreadId').value = '';
$('telegramChatId').value = '';
$('voiceSessionId').value = '';
return;
}
const workspace = state.workspaceById[state.selectedDealId];
if (!workspace) {
$('dealWorkspace').innerHTML = 'Загружаю карточку сделки...';
return;
}
const { deal, lead, communications, offers, documents, invoices, payments, timeline, counterparty, escalations, tasks } = workspace;
const bindings = workspaceBindings(workspace);
$('dealTitle').textContent = deal.title;
$('dealSubtitle').textContent = `${stageLabel(deal.stage_id)}${deal.current_channel || 'канал не задан'}${deal.status}`;
$('dealStageBadge').textContent = stageLabel(deal.stage_id);
$('dealStageSelect').value = deal.stage_id;
$('telegramThreadId').value = bindings.telegramThreadId || '';
$('telegramChatId').value = bindings.telegramChatId || '';
$('voiceSessionId').value = bindings.voiceSessionId || '';
$('dealMetrics').innerHTML = [
workspaceMetric('Канал', deal.current_channel || '—'),
workspaceMetric('Следующий шаг', deal.next_action_type || 'не задан'),
workspaceMetric('Сумма', `${Number(deal.final_amount || deal.estimated_amount || 0).toLocaleString('ru-RU')} ${deal.currency || 'KZT'}`),
workspaceMetric('Customer link', deal.customer_id || 'не привязан'),
].join('');
$('dealWorkspace').innerHTML = `
<div class="sales-workspace-grid">
<section class="sales-section-card">
<h3>Lead / Deal core</h3>
<div class="sales-kv">
${kvItem('Lead', lead?.lead_id || '—')}
${kvItem('Имя', lead?.full_name || '—')}
${kvItem('Телефон', lead?.phone || '—')}
${kvItem('Предпочтительный канал', deal.preferred_channel || '—')}
${kvItem('Score', lead?.lead_score || '—')}
${kvItem('Потребность', deal.need_summary || lead?.initial_need_summary || '—')}
</div>
</section>
<section class="sales-section-card">
<h3>Counterparty</h3>
<div class="sales-kv">
${kvItem('Компания', counterparty?.company_name || '—')}
${kvItem('Контакт', counterparty?.full_name || '—')}
${kvItem('BIN/IIN', counterparty?.bin_iin || '—')}
${kvItem('Email для документов', counterparty?.email_for_docs || '—')}
</div>
</section>
<section class="sales-section-card">
<h3>Коммуникации</h3>
${listRows(
communications.map((item) => ({
...item,
headline: `${item.channel_type === 'voice' ? 'Voice' : 'Text'}${item.direction}`,
detail: item.summary || item.subject || item.result_code || 'Без summary',
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Коммерческий пакет</h3>
${listRows(
offers.map((item) => ({
...item,
headline: `${item.title}${item.status}`,
detail: `${Number(item.total_amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Документы и счета</h3>
${listRows(
[...documents, ...invoices].map((item) => ({
...item,
headline: item.document_id
? `${item.document_type}${item.status}`
: `${item.invoice_number}${item.status}`,
detail: item.document_id
? (item.file_url || 'Файл еще не прикреплен')
: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Оплаты, задачи, эскалации</h3>
${listRows(
[
...payments.map((item) => ({
...item,
headline: `Оплата • ${item.status}`,
detail: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
})),
...tasks.map((item) => ({
...item,
headline: `Task ${item.task_type}`,
detail: `${item.status} ${item.run_at}`,
})),
...escalations.map((item) => ({
...item,
headline: `Эскалация ${item.severity}`,
detail: item.reason,
})),
],
'headline',
'detail',
)}
</section>
<section class="sales-section-card">
<h3>Timeline</h3>
<div class="sales-stream">
${timeline.length ? timeline
.map(
(item) => `
<div class="sales-stream-item">
<div class="sales-stream-title">${item.title}</div>
<div class="sales-stream-time">${item.ts}</div>
<div class="sales-stream-body">${item.body}</div>
</div>
`,
)
.join('') : '<div class="sales-workspace-empty">Пока нет timeline событий.</div>'}
</div>
</section>
</div>
`;
}
function latestCommunication(workspace, channelType = '') {
const items = workspace?.communications || [];
if (!channelType) {
return items[0] || null;
}
return items.find((item) => item.channel_type === channelType) || items[0] || null;
}
function workspaceBindings(workspace) {
const bindings = {
telegramThreadId: '',
telegramChatId: '',
voiceSessionId: '',
};
for (const item of workspace?.communications || []) {
const metadata = item.metadata || {};
if (!bindings.telegramThreadId && metadata.telegram_thread_id) {
bindings.telegramThreadId = metadata.telegram_thread_id;
}
if (!bindings.telegramChatId && metadata.telegram_chat_id) {
bindings.telegramChatId = metadata.telegram_chat_id;
}
if (!bindings.voiceSessionId && metadata.voice_session_id) {
bindings.voiceSessionId = metadata.voice_session_id;
}
}
return bindings;
}
async function loadDashboard() {
state.dashboard = await api('api/v1/dashboard');
renderDashboard();
}
async function loadDeals() {
const query = ($('salesSearch').value || '').trim();
const params = new URLSearchParams();
if (query) {
params.set('query', query);
}
const path = params.toString() ? `api/v1/deals?${params.toString()}` : 'api/v1/deals';
state.deals = await api(path);
renderBoard();
}
async function loadLeads() {
state.leads = await api('api/v1/leads');
}
async function loadWorkspace(dealId) {
state.workspaceById[dealId] = await api(`api/v1/deals/${encodeURIComponent(dealId)}/workspace`);
renderWorkspace();
}
async function refreshAll() {
await Promise.all([loadDashboard(), loadDeals(), loadLeads()]);
if (state.selectedDealId) {
await loadWorkspace(state.selectedDealId);
}
}
async function selectDeal(dealId) {
state.selectedDealId = dealId;
renderBoard();
renderWorkspace();
await loadWorkspace(dealId);
}
function selectedWorkspace() {
return state.workspaceById[state.selectedDealId] || null;
}
async function createLead() {
try {
const payload = {
source_type: 'crm',
source_channel: $('leadSourceChannel').value,
full_name: $('leadName').value.trim(),
company_name: $('leadCompany').value.trim() || null,
phone: $('leadPhone').value.trim() || null,
email: $('leadEmail').value.trim() || null,
lead_temperature: $('leadTemperature').value,
lead_score: Number($('leadScore').value || 68),
initial_need_summary: $('leadNeedSummary').value.trim() || null,
preferred_channel: $('leadPreferredChannel').value,
assigned_agent_type: $('leadPreferredChannel').value === 'voice' ? 'voice_ai' : 'text_ai',
status: 'new_qualified_lead',
priority: 3,
title: $('leadName').value.trim() ? `Лид: ${$('leadName').value.trim()}` : null,
};
await api('api/v1/leads', { method: 'POST', body: JSON.stringify(payload) });
log('Лид создан', payload);
await refreshAll();
} catch (error) {
log('Ошибка создания лида', { error: error.message });
}
}
async function seedDemo() {
$('leadName').value = 'Demo Buyer';
$('leadCompany').value = 'KazTrade Labs';
$('leadPhone').value = '+77015551234';
$('leadEmail').value = 'buyer@kaztrade.test';
$('leadNeedSummary').value = 'Нужен расчет и оффер на пакет услуг с возможностью быстро перейти из чата в звонок.';
$('leadPreferredChannel').value = 'telegram';
$('leadTemperature').value = 'hot';
$('leadScore').value = '81';
await createLead();
}
async function postQuickAction(handler, successLabel) {
if (!state.selectedDealId) {
log('Сначала выберите сделку.');
return;
}
try {
await handler();
log(successLabel, { deal_id: state.selectedDealId });
await refreshAll();
} catch (error) {
log('Ошибка действия', { error: error.message });
}
}
async function startTextSession() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/communications/text`, {
method: 'POST',
body: JSON.stringify({
channel_type: 'text',
direction: 'outbound',
agent_type: 'text_ai',
subject: 'Текстовый follow-up по сделке',
}),
}),
'Текстовая сессия создана',
);
}
async function startVoiceSession() {
const workspace = selectedWorkspace();
const phone = workspace?.lead?.phone || workspace?.counterparty?.phone_for_docs || '+77010000000';
return postQuickAction(
() => api('api/v1/calls/outbound', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
phone_number: phone,
provider: 'voice_assistant',
subject: 'Голосовой follow-up по сделке',
}),
}),
'Голосовая сессия создана',
);
}
async function switchToText() {
const workspace = selectedWorkspace();
const communication = workspace?.communications?.[0];
if (!communication) {
log('Нет коммуникации для channel switch.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
method: 'POST',
body: JSON.stringify({
to_channel: 'telegram',
reason_for_channel_switch: 'После звонка нужно зафиксировать условия письменно',
}),
}),
'Сделка переведена в текст',
);
}
async function switchToVoice() {
const workspace = selectedWorkspace();
const communication = workspace?.communications?.[0];
if (!communication) {
log('Нет коммуникации для channel switch.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
method: 'POST',
body: JSON.stringify({
to_channel: 'voice',
reason_for_channel_switch: 'Сложный кейс, удобнее перейти в звонок',
}),
}),
'Сделка переведена в voice',
);
}
async function createOffer() {
const workspace = selectedWorkspace();
const amount = Number(workspace?.deal?.estimated_amount || workspace?.deal?.final_amount || 150000);
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/offers`, {
method: 'POST',
body: JSON.stringify({
offer_type: 'offer',
title: `Коммерческое предложение • ${workspace?.deal?.title || 'Сделка'}`,
description: 'Автогенерированный оффер для быстрого запуска продажного цикла.',
line_items: [{ name: 'Основной пакет', quantity: 1, price: amount }],
pricing: { subtotal: amount, vat: 0, total: amount },
total_amount: amount,
currency: workspace?.deal?.currency || 'KZT',
}),
}),
'Оффер создан',
);
}
async function createInvoice() {
const workspace = selectedWorkspace();
const amount = Number(workspace?.deal?.final_amount || workspace?.deal?.estimated_amount || 150000);
const firstDocument = workspace?.documents?.[0];
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/invoices`, {
method: 'POST',
body: JSON.stringify({
basis_document_id: firstDocument?.document_id || null,
amount,
currency: workspace?.deal?.currency || 'KZT',
due_date: $('nextActionAt').value.trim() || null,
line_items: [{ title: workspace?.deal?.title || 'Сделка', amount }],
metadata: { generated_from: 'sales_ui' },
}),
}),
'Счет создан',
);
}
async function markPaid() {
const workspace = selectedWorkspace();
const invoice = workspace?.invoices?.[0];
if (!invoice) {
log('Сначала создайте счет.');
return;
}
return postQuickAction(
() => api('api/v1/payments/webhook', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
invoice_id: invoice.invoice_id,
payment_provider: 'manual',
amount: invoice.amount,
currency: invoice.currency,
status: 'success',
payment_method: 'bank_transfer',
}),
}),
'Оплата зафиксирована',
);
}
async function saveStage() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/change-stage`, {
method: 'POST',
body: JSON.stringify({
stage_id: $('dealStageSelect').value,
reason: 'Ручное обновление стадии из sales workspace',
}),
}),
'Этап обновлен',
);
}
async function saveNextAction() {
return postQuickAction(
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/schedule-next-action`, {
method: 'POST',
body: JSON.stringify({
next_action_type: $('nextActionType').value.trim() || 'follow_up',
next_action_at: $('nextActionAt').value.trim() || null,
payload: { source: 'sales_ui' },
}),
}),
'Next action сохранен',
);
}
async function bindTelegramThread() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'text');
const threadId = $('telegramThreadId').value.trim();
if (!communication) {
log('Сначала откройте text-сессию по сделке.');
return;
}
if (!threadId) {
log('Укажите telegram thread id.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
method: 'POST',
body: JSON.stringify({
channel_provider: 'telegram',
telegram_thread_id: threadId,
telegram_chat_id: $('telegramChatId').value.trim() || null,
metadata: { source: 'sales_ui_binding' },
}),
}),
'Telegram thread привязан к сделке',
);
}
async function bindVoiceSession() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'voice');
const voiceSessionId = $('voiceSessionId').value.trim();
if (!communication) {
log('Сначала откройте voice-сессию по сделке.');
return;
}
if (!voiceSessionId) {
log('Укажите voice session id.');
return;
}
return postQuickAction(
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
method: 'POST',
body: JSON.stringify({
channel_provider: 'voice',
voice_session_id: voiceSessionId,
metadata: { source: 'sales_ui_binding' },
}),
}),
'Voice session привязана к сделке',
);
}
async function sendTelegramReply() {
const workspace = selectedWorkspace();
const communication = latestCommunication(workspace, 'text');
const threadId = $('telegramThreadId').value.trim();
const body = $('telegramReplyText').value.trim();
if (!threadId) {
log('Сначала привяжите Telegram thread.');
return;
}
if (!body) {
log('Введите текст сообщения.');
return;
}
return postQuickAction(
() => api('api/v1/messages/outbound', {
method: 'POST',
body: JSON.stringify({
deal_id: state.selectedDealId,
communication_id: communication?.communication_id || null,
channel_provider: 'telegram',
sender_type: 'human',
sender_id: state.user,
body,
metadata: {
telegram_thread_id: threadId,
telegram_chat_id: $('telegramChatId').value.trim() || null,
source: 'sales_ui_temp_telegram',
},
}),
}),
'Сообщение поставлено в Telegram bridge',
).then(() => {
$('telegramReplyText').value = '';
});
}
function handleBoardClick(event) {
const button = event.target.closest('[data-deal-id]');
if (!button) {
return;
}
selectDeal(button.dataset.dealId).catch((error) => {
log('Ошибка загрузки сделки', { error: error.message });
});
}
function bindEvents() {
$('loginBtn').addEventListener('click', login);
$('logoutBtn').addEventListener('click', logout);
$('refreshSalesBtn').addEventListener('click', () => refreshAll().catch((error) => log('Ошибка обновления', { error: error.message })));
$('salesSearch').addEventListener('input', renderBoard);
$('salesBoard').addEventListener('click', handleBoardClick);
$('createLeadBtn').addEventListener('click', createLead);
$('seedDemoBtn').addEventListener('click', seedDemo);
$('startTextBtn').addEventListener('click', startTextSession);
$('startVoiceBtn').addEventListener('click', startVoiceSession);
$('switchToTextBtn').addEventListener('click', switchToText);
$('switchToVoiceBtn').addEventListener('click', switchToVoice);
$('createOfferBtn').addEventListener('click', createOffer);
$('createInvoiceBtn').addEventListener('click', createInvoice);
$('markPaidBtn').addEventListener('click', markPaid);
$('saveStageBtn').addEventListener('click', saveStage);
$('saveNextActionBtn').addEventListener('click', saveNextAction);
$('bindTelegramBtn').addEventListener('click', bindTelegramThread);
$('bindVoiceSessionBtn').addEventListener('click', bindVoiceSession);
$('sendTelegramReplyBtn').addEventListener('click', sendTelegramReply);
}
async function init() {
renderStageOptions();
bindEvents();
updateProfile();
await checkGateway();
if (restoreStoredSession()) {
log('Сессия восстановлена', { user: state.user, role: state.role });
await refreshAll();
}
}
init().catch((error) => {
log('Ошибка инициализации', { error: error.message });
});