928 lines
32 KiB
JavaScript
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 });
|
|
});
|