restore(ui): recover frontend deleted in 8e51290 (login/operator/supervisor/analyst/admin/sales)
This commit is contained in:
+927
@@ -0,0 +1,927 @@
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user