sales fix
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
||||
<a class="nav-link active" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
||||
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Страницы</div>
|
||||
|
||||
+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 });
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KonturCC Продажи</title>
|
||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/sales/assets/styles.css?v=sales-v1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">S</div>
|
||||
<div>
|
||||
<div class="brand-title">KonturCC</div>
|
||||
<div class="brand-sub">Sales orchestration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Рабочие зоны</div>
|
||||
<a class="nav-link" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
||||
<a class="nav-link" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
||||
<a class="nav-link" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
||||
<a class="nav-link" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
||||
<a class="nav-link active" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Секции</div>
|
||||
<a class="nav-link active" href="#overview"><span class="nav-bullet"></span>Воронка</a>
|
||||
<a class="nav-link" href="#dealPanel"><span class="nav-bullet"></span>Карточка сделки</a>
|
||||
<a class="nav-link" href="#createLeadPanel"><span class="nav-bullet"></span>Новый лид</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<strong>Модуль продаж</strong>
|
||||
<p>Одна сделка живет через text и voice. Здесь же этапы, офферы, счета, оплаты, эскалации и handoff между каналами.</p>
|
||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="workspace-topbar reveal">
|
||||
<div class="search-box">
|
||||
<input id="salesSearch" class="app-search" placeholder="Найти по сделке, клиенту, задаче или каналу..." />
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
|
||||
<button id="refreshSalesBtn" class="btn ghost" type="button">Обновить</button>
|
||||
<div class="profile-chip">
|
||||
<div class="avatar-badge">SL</div>
|
||||
<div class="profile-meta">
|
||||
<div class="profile-name" id="profileName">Экран продаж</div>
|
||||
<div class="profile-role" id="profileRole">Omnichannel pipeline</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout sales-layout">
|
||||
<section class="page-hero reveal" id="overview">
|
||||
<p class="eyebrow">Sales Control Room</p>
|
||||
<h1>Омниканальная воронка продаж</h1>
|
||||
<p class="hint">Теплый лид попадает в CRM, дальше система ведет его через коммуникации, оффер, документы, счет, оплату и post-sale handoff без потери контекста.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel auth reveal" id="authPanel">
|
||||
<h2>Сессия</h2>
|
||||
<div class="row">
|
||||
<label>Пользователь</label>
|
||||
<input id="sessionUser" value="admin" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Роль</label>
|
||||
<select id="sessionRole">
|
||||
<option value="admin" selected>Администратор</option>
|
||||
<option value="supervisor">Супервизор</option>
|
||||
<option value="operator">Оператор</option>
|
||||
<option value="analyst">Аналитик</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Пароль</label>
|
||||
<input id="sessionPassword" type="password" value="admin123" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="loginBtn" class="btn" type="button">Войти</button>
|
||||
</div>
|
||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
||||
</section>
|
||||
|
||||
<section class="sales-dashboard-grid reveal" id="salesDashboard"></section>
|
||||
|
||||
<section class="sales-main-grid">
|
||||
<article class="panel reveal sales-board-panel">
|
||||
<div class="sales-board-head">
|
||||
<div>
|
||||
<h2>Воронка</h2>
|
||||
<p class="hint">Колонки собраны по крупным бизнес-блокам из ТЗ: вход, коммуникация, коммерция, оформление, финансы и закрытие.</p>
|
||||
</div>
|
||||
<div class="sales-toolbar">
|
||||
<button id="openCreateLeadBtn" class="btn" type="button">Добавить лид</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="salesBoard" class="sales-board"></div>
|
||||
</article>
|
||||
|
||||
<aside class="sales-detail-stack">
|
||||
<article class="panel reveal sales-detail-panel" id="dealPanel">
|
||||
<div class="sales-detail-head">
|
||||
<div>
|
||||
<p class="eyebrow">Карточка сделки</p>
|
||||
<h2 id="dealTitle">Выберите сделку</h2>
|
||||
<p id="dealSubtitle" class="hint">Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.</p>
|
||||
</div>
|
||||
<div id="dealStageBadge" class="status-pill">Нет данных</div>
|
||||
</div>
|
||||
|
||||
<div class="sales-quick-actions">
|
||||
<button id="startTextBtn" class="btn ghost" type="button">Текстовая сессия</button>
|
||||
<button id="startVoiceBtn" class="btn ghost" type="button">Голосовая сессия</button>
|
||||
<button id="switchToTextBtn" class="btn ghost" type="button">Voice -> Text</button>
|
||||
<button id="switchToVoiceBtn" class="btn ghost" type="button">Text -> Voice</button>
|
||||
<button id="createOfferBtn" class="btn ghost" type="button">Создать оффер</button>
|
||||
<button id="createInvoiceBtn" class="btn ghost" type="button">Выставить счет</button>
|
||||
<button id="markPaidBtn" class="btn ghost" type="button">Отметить оплату</button>
|
||||
</div>
|
||||
|
||||
<div class="inline-form compact">
|
||||
<select id="dealStageSelect"></select>
|
||||
<button id="saveStageBtn" class="btn" type="button">Сменить этап</button>
|
||||
</div>
|
||||
|
||||
<div class="inline-form compact">
|
||||
<input id="nextActionType" placeholder="Следующий шаг, например follow_up_call" />
|
||||
<input id="nextActionAt" placeholder="2026-05-10T12:00:00+05:00" />
|
||||
<button id="saveNextActionBtn" class="btn ghost" type="button">Сохранить next action</button>
|
||||
</div>
|
||||
|
||||
<div class="sales-bind-grid">
|
||||
<input id="telegramThreadId" placeholder="Telegram thread id" />
|
||||
<input id="telegramChatId" placeholder="Telegram chat id" />
|
||||
<button id="bindTelegramBtn" class="btn ghost" type="button">Привязать Telegram</button>
|
||||
<input id="voiceSessionId" placeholder="Voice session id" />
|
||||
<button id="bindVoiceSessionBtn" class="btn ghost" type="button">Привязать voice session</button>
|
||||
</div>
|
||||
|
||||
<div class="sales-inline-message">
|
||||
<textarea id="telegramReplyText" rows="3" class="json-textarea" placeholder="Временная текстовая отправка через Telegram bot в рамках этой сделки."></textarea>
|
||||
<button id="sendTelegramReplyBtn" class="btn" type="button">Отправить в Telegram</button>
|
||||
</div>
|
||||
|
||||
<div class="sales-detail-metrics" id="dealMetrics"></div>
|
||||
<div id="dealWorkspace" class="sales-workspace-empty">Выберите карточку на воронке, чтобы увидеть подробности.</div>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="createLeadPanel">
|
||||
<h2>Новый лид</h2>
|
||||
<div class="inline-form split-two">
|
||||
<input id="leadName" placeholder="Имя клиента" />
|
||||
<input id="leadCompany" placeholder="Компания" />
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<input id="leadPhone" placeholder="Телефон" />
|
||||
<input id="leadEmail" placeholder="Email" />
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<select id="leadSourceChannel">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Webchat</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="voice">Voice</option>
|
||||
</select>
|
||||
<select id="leadPreferredChannel">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Webchat</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="voice">Voice</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<select id="leadTemperature">
|
||||
<option value="warm">Warm</option>
|
||||
<option value="hot">Hot</option>
|
||||
</select>
|
||||
<input id="leadScore" type="number" min="0" max="100" value="68" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="leadNeedSummary">Краткая потребность</label>
|
||||
<textarea id="leadNeedSummary" rows="4" class="json-textarea" placeholder="Например: клиенту нужен расчет, коммерческое предложение и созвон сегодня."></textarea>
|
||||
</div>
|
||||
<div class="actions compact-actions">
|
||||
<button id="createLeadBtn" class="btn" type="button">Создать лид</button>
|
||||
<button id="seedDemoBtn" class="btn ghost" type="button">Создать demo-сделку</button>
|
||||
</div>
|
||||
<pre id="salesLog" class="output">Здесь появятся ответы сервиса и состояние быстрых действий.</pre>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/sales/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,373 @@
|
||||
.sales-layout {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sales-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sales-kpi-card {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(51, 102, 232, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, #ffffff, #f8fbff);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.sales-kpi-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sales-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.sales-kpi-note {
|
||||
margin-top: 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sales-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.95fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sales-detail-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sales-board-panel,
|
||||
.sales-detail-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.sales-board-head,
|
||||
.sales-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sales-board-head h2,
|
||||
.sales-detail-head h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sales-board {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.sales-column {
|
||||
min-height: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 255, 0.92));
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.sales-column-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-column-title {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sales-column-count {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid var(--primary-line);
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sales-column-note {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sales-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.sales-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--primary-line);
|
||||
box-shadow: 0 12px 24px rgba(16, 33, 61, 0.08);
|
||||
}
|
||||
|
||||
.sales-card.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 16px 30px rgba(51, 102, 232, 0.16);
|
||||
}
|
||||
|
||||
.sales-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sales-card-title {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sales-card-priority {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #9a4f00;
|
||||
background: #fff2d8;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sales-card-body {
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sales-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sales-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: #f4f7fd;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sales-workspace-empty {
|
||||
margin-top: 18px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
color: var(--text-soft);
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.sales-quick-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sales-detail-metrics {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-bind-grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-inline-message {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sales-metric {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.sales-metric-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sales-metric-value {
|
||||
margin-top: 6px;
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sales-workspace-grid {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sales-section-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.sales-section-card h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sales-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-kv-item {
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.sales-kv-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sales-kv-value {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sales-stream {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sales-stream-item {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 0 0 0 12px;
|
||||
}
|
||||
|
||||
.sales-stream-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sales-stream-time {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.sales-stream-body {
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.sales-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sales-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
.sales-list-row strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sales-list-row span {
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.sales-board {
|
||||
grid-template-columns: repeat(6, minmax(260px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.sales-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sales-bind-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sales-detail-metrics,
|
||||
.sales-kv {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sales-bind-grid,
|
||||
.sales-inline-message {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user