diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 365bed6..5420b87 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -315,6 +315,10 @@ def test_operator_ui_can_fix_customer_name_from_call_views(): assert 'id="browserPhoneEditNameBtn"' in index_html assert "function openLiveCallNameEditor" in app_js assert "function saveLiveCallCustomerName" in app_js + assert "function saveCustomerProfileName" in app_js + assert "function openCustomerProfileNameEditor" in app_js + assert "data-customer-name-form" in app_js + assert 'data-customer-action="edit-name"' in app_js assert "function applyCustomerNamePatchLocally" in app_js assert "function updateLiveCallNameEditorsUi" in app_js assert "function handleLiveCallTableClick" in app_js @@ -323,6 +327,8 @@ def test_operator_ui_can_fix_customer_name_from_call_views(): assert ".live-call-name-editor {" in styles_css assert ".call-window-name-editor {" in styles_css assert ".live-call-card-actions {" in styles_css + assert ".customer-profile-name-editor {" in styles_css + assert ".customer-profile-name-row {" in styles_css def test_operator_ui_surfaces_voice_name_state_across_call_views(): @@ -343,8 +349,8 @@ def test_operator_ui_surfaces_voice_name_state_across_call_views(): assert ".micro-badge.name-confirmed {" in styles_css assert ".micro-badge.name-followup {" in styles_css assert ".micro-badge.name-missing {" in styles_css - assert "/operator/assets/styles.css?v=track21-voice-name-edit1" in index_html - assert "/operator/assets/app.js?v=track40-voice-name-edit1" in index_html + assert "/operator/assets/styles.css?v=track22-customer-profile-name1" in index_html + assert "/operator/assets/app.js?v=track41-customer-profile-name1" in index_html def test_operator_ui_hides_whatsapp_behind_feature_flag(): diff --git a/ui/operator/app.js b/ui/operator/app.js index 985bff7..57d1e85 100644 --- a/ui/operator/app.js +++ b/ui/operator/app.js @@ -41,6 +41,13 @@ const state = { historyById: {}, pendingHistoryById: {}, historyErrorsById: {}, + nameEditor: { + open: false, + customerId: '', + draft: '', + saving: false, + error: '', + }, }, telegram: { threads: [], @@ -1067,6 +1074,40 @@ function emptyCustomerSpotlightMarkup(title, description) { `; } +function customerProfileNameEditorMarkup(customer) { + const editor = state.customers.nameEditor; + const isOpen = editor.open && editor.customerId === customer.customer_id; + const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : 'Имя сохранится в профиле клиента и voice-контуре.'); + return ` +
+
+

${escapeHtml(customer.display_name || 'Без имени')}

+

${escapeHtml(customerLeadCompany(customer))}

+
+ +
+ ${isOpen ? ` +
+
Как сохранить имя клиента в профиле
+
+ + + +
+

${escapeHtml(statusText)}

+
+ ` : ''} + `; +} + function customerSpotlightMarkup(customer, options = {}) { const { pageView = false } = options; const index = Math.max(customerIndex(customer), 0); @@ -1162,8 +1203,7 @@ function customerSpotlightMarkup(customer, options = {}) {
${escapeHtml(customerInitials(customer.display_name))}
Клиент ${escapeHtml(customer.customer_id)}
-

${escapeHtml(customer.display_name || 'Без имени')}

-

${escapeHtml(customerLeadCompany(customer))}

+ ${customerProfileNameEditorMarkup(customer)}
${escapeHtml(source.label)} @@ -1374,6 +1414,13 @@ function renderCustomerList() { } function selectCustomer(customerId) { + if (state.customers.nameEditor.customerId && state.customers.nameEditor.customerId !== customerId) { + state.customers.nameEditor.open = false; + state.customers.nameEditor.customerId = ''; + state.customers.nameEditor.draft = ''; + state.customers.nameEditor.saving = false; + state.customers.nameEditor.error = ''; + } state.customers.selectedCustomerId = customerId || ''; if (customerId) { $('interactionCustomerId').value = customerId; @@ -4122,6 +4169,28 @@ function liveCallCustomerId(item) { return interactionById(item?.interaction_id || '')?.customer_id || ''; } +function closeCustomerProfileNameEditor() { + state.customers.nameEditor.open = false; + state.customers.nameEditor.customerId = ''; + state.customers.nameEditor.draft = ''; + state.customers.nameEditor.saving = false; + state.customers.nameEditor.error = ''; + renderCustomerSpotlight(); +} + +function openCustomerProfileNameEditor(customerId = state.customers.selectedCustomerId) { + const customer = state.customers.items.find((item) => item.customer_id === customerId) || null; + if (!customer) { + return; + } + state.customers.nameEditor.open = true; + state.customers.nameEditor.customerId = customer.customer_id; + state.customers.nameEditor.draft = customer.display_name || ''; + state.customers.nameEditor.saving = false; + state.customers.nameEditor.error = ''; + renderCustomerSpotlight(); +} + function closeLiveCallNameEditor() { state.liveCalls.nameEditor.open = false; state.liveCalls.nameEditor.callId = ''; @@ -4209,10 +4278,35 @@ function applyCustomerNamePatchLocally({ customerId, callId, displayName }) { customer_name_source: 'manual', }; }); + if (state.liveCalls.nameEditor.customerId === customerId && !state.liveCalls.nameEditor.saving) { + state.liveCalls.nameEditor.draft = normalizedName; + } + if (state.customers.nameEditor.customerId === customerId && !state.customers.nameEditor.saving) { + state.customers.nameEditor.draft = normalizedName; + } renderCustomerList(); refreshVoiceSummaryDependentViews(); } +async function persistCustomerDisplayName({ customerId, displayName, callId = '' }) { + const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName, source: 'manual' }), + }); + const savedName = data?.display_name || displayName; + applyCustomerNamePatchLocally({ + customerId, + callId, + displayName: savedName, + }); + ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {}); + if (callId) { + loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {}); + } + refreshLiveCallsInBackground(); + return savedName; +} + async function saveLiveCallCustomerName() { const { customerId, callId } = state.liveCalls.nameEditor; const displayName = String(state.liveCalls.nameEditor.draft || '').trim().replace(/\s+/g, ' '); @@ -4228,19 +4322,9 @@ async function saveLiveCallCustomerName() { state.liveCalls.nameEditor.error = ''; updateLiveCallNameEditorsUi(); try { - const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, { - method: 'PATCH', - body: JSON.stringify({ display_name: displayName, source: 'manual' }), - }); - applyCustomerNamePatchLocally({ - customerId, - callId, - displayName: data?.display_name || displayName, - }); + const savedName = await persistCustomerDisplayName({ customerId, callId, displayName }); + const data = { display_name: savedName }; closeLiveCallNameEditor(); - ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {}); - loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {}); - refreshLiveCallsInBackground(); log('Имя клиента обновлено оператором', { customer_id: customerId, call_id: callId, name: data?.display_name || displayName }); } catch (err) { state.liveCalls.nameEditor.saving = false; @@ -4249,6 +4333,36 @@ async function saveLiveCallCustomerName() { } } +async function saveCustomerProfileName() { + const customerId = state.customers.nameEditor.customerId || state.customers.selectedCustomerId; + const displayName = String(state.customers.nameEditor.draft || '').trim().replace(/\s+/g, ' '); + if (!customerId) { + return; + } + if (displayName.length < 2) { + state.customers.nameEditor.error = 'Введите имя клиента минимум из 2 символов.'; + renderCustomerSpotlight(); + return; + } + const currentName = String(selectedCustomer()?.display_name || '').trim(); + if (displayName === currentName) { + closeCustomerProfileNameEditor(); + return; + } + state.customers.nameEditor.saving = true; + state.customers.nameEditor.error = ''; + renderCustomerSpotlight(); + try { + const savedName = await persistCustomerDisplayName({ customerId, displayName }); + closeCustomerProfileNameEditor(); + log('Имя клиента обновлено из профиля', { customer_id: customerId, name: savedName }); + } catch (err) { + state.customers.nameEditor.saving = false; + state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; + renderCustomerSpotlight(); + } +} + function liveCallNameEditorMeta(item, customerId) { if (!item || !customerId) { return 'Выберите звонок, связанный с клиентом, чтобы исправить имя.'; @@ -6232,6 +6346,14 @@ async function handleCustomerActionClick(event) { if (!customer) { return; } + if (button.dataset.customerAction === 'edit-name') { + openCustomerProfileNameEditor(customer.customer_id); + return; + } + if (button.dataset.customerAction === 'cancel-name-edit') { + closeCustomerProfileNameEditor(); + return; + } if (button.dataset.customerAction === 'workspace') { $('interactionCustomerId').value = customer.customer_id; window.location.hash = '#workspace'; @@ -6266,6 +6388,37 @@ async function handleCustomerActionClick(event) { } } +function handleCustomerProfileNameInput(event) { + const input = event.target?.closest?.('[data-customer-name-input]'); + if (!input) { + return; + } + state.customers.nameEditor.draft = input.value || ''; + if (!state.customers.nameEditor.error) { + return; + } + state.customers.nameEditor.error = ''; + const form = input.closest('[data-customer-name-form]'); + const status = form?.querySelector?.('[data-customer-name-status]'); + if (status) { + status.textContent = 'Имя сохранится в профиле клиента и voice-контуре.'; + status.classList.remove('error'); + } +} + +function handleCustomerProfileNameSubmit(event) { + const form = event.target?.closest?.('[data-customer-name-form]'); + if (!form) { + return; + } + event.preventDefault(); + saveCustomerProfileName().catch((err) => { + state.customers.nameEditor.saving = false; + state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; + renderCustomerSpotlight(); + }); +} + function wire() { $('loginBtn').addEventListener('click', login); $('corporateLoginBtn').addEventListener('click', startCorporateLogin); @@ -6313,7 +6466,11 @@ function wire() { openCustomerProfile(row.dataset.customerId || ''); }); $('customerSpotlight')?.addEventListener('click', handleCustomerActionClick); + $('customerSpotlight')?.addEventListener('input', handleCustomerProfileNameInput); + $('customerSpotlight')?.addEventListener('submit', handleCustomerProfileNameSubmit); $('customerProfileContent').addEventListener('click', handleCustomerActionClick); + $('customerProfileContent').addEventListener('input', handleCustomerProfileNameInput); + $('customerProfileContent').addEventListener('submit', handleCustomerProfileNameSubmit); $('customerProfileBackBtn').addEventListener('click', () => { window.location.hash = '#customers-page'; }); diff --git a/ui/operator/index.html b/ui/operator/index.html index fd89be7..9b05f85 100644 --- a/ui/operator/index.html +++ b/ui/operator/index.html @@ -8,7 +8,7 @@ - +
@@ -640,6 +640,6 @@ - + diff --git a/ui/operator/styles.css b/ui/operator/styles.css index 7cb04ee..2c76e28 100644 --- a/ui/operator/styles.css +++ b/ui/operator/styles.css @@ -1100,6 +1100,23 @@ textarea::placeholder { min-width: 0; } +.customer-profile-name-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-top: 8px; +} + +.customer-profile-name-stack { + min-width: 0; + flex: 1; +} + +.customer-profile-name-trigger { + flex: 0 0 auto; +} + .customer-profile-kicker { color: var(--text-faint); font-size: 11px; @@ -1124,6 +1141,35 @@ textarea::placeholder { line-height: 1.5; } +.customer-profile-name-editor { + display: grid; + gap: 10px; + margin-top: 14px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(163, 183, 222, 0.55); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 255, 0.96)); +} + +.customer-profile-name-editor-label { + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.customer-profile-name-editor-form { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.customer-profile-name-editor-form input { + min-width: 220px; + flex: 1 1 260px; +} + .customer-profile-badges { display: flex; flex-wrap: wrap; @@ -5189,6 +5235,11 @@ body.analytics-drilldown-open { flex-direction: column; } + .customer-profile-name-row { + flex-direction: column; + align-items: stretch; + } + .customer-profile-badges { justify-content: flex-start; max-width: none; @@ -5225,7 +5276,8 @@ body.analytics-drilldown-open { } .call-window-name-form, - .live-call-name-editor-form { + .live-call-name-editor-form, + .customer-profile-name-editor-form { grid-template-columns: 1fr; }