Add customer profile name editing for operator
This commit is contained in:
@@ -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():
|
||||
|
||||
+171
-14
@@ -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 `
|
||||
<div class="customer-profile-name-row">
|
||||
<div class="customer-profile-name-stack">
|
||||
<h3>${escapeHtml(customer.display_name || 'Без имени')}</h3>
|
||||
<p>${escapeHtml(customerLeadCompany(customer))}</p>
|
||||
</div>
|
||||
<button class="btn ghost customer-profile-name-trigger" type="button" data-customer-action="edit-name">
|
||||
${isOpen ? 'Редактирование имени' : 'Изменить имя'}
|
||||
</button>
|
||||
</div>
|
||||
${isOpen ? `
|
||||
<form class="customer-profile-name-editor" data-customer-name-form>
|
||||
<div class="customer-profile-name-editor-label">Как сохранить имя клиента в профиле</div>
|
||||
<div class="customer-profile-name-editor-form">
|
||||
<input
|
||||
type="text"
|
||||
value="${escapeHtml(editor.draft)}"
|
||||
placeholder="Введите имя клиента"
|
||||
data-customer-name-input
|
||||
${editor.saving ? 'disabled' : ''}
|
||||
/>
|
||||
<button class="btn" type="submit" ${editor.saving ? 'disabled' : ''}>${editor.saving ? 'Сохраняем...' : 'Сохранить имя'}</button>
|
||||
<button class="btn ghost" type="button" data-customer-action="cancel-name-edit" ${editor.saving ? 'disabled' : ''}>Отмена</button>
|
||||
</div>
|
||||
<p class="hint${editor.error ? ' error' : ''}" data-customer-name-status>${escapeHtml(statusText)}</p>
|
||||
</form>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function customerSpotlightMarkup(customer, options = {}) {
|
||||
const { pageView = false } = options;
|
||||
const index = Math.max(customerIndex(customer), 0);
|
||||
@@ -1162,8 +1203,7 @@ function customerSpotlightMarkup(customer, options = {}) {
|
||||
<div class="customer-profile-avatar">${escapeHtml(customerInitials(customer.display_name))}</div>
|
||||
<div class="customer-profile-copy">
|
||||
<div class="customer-profile-kicker">Клиент ${escapeHtml(customer.customer_id)}</div>
|
||||
<h3>${escapeHtml(customer.display_name || 'Без имени')}</h3>
|
||||
<p>${escapeHtml(customerLeadCompany(customer))}</p>
|
||||
${customerProfileNameEditorMarkup(customer)}
|
||||
</div>
|
||||
<div class="customer-profile-badges">
|
||||
<span class="${source.className}">${escapeHtml(source.label)}</span>
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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?v=track21-voice-name-edit1" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track22-customer-profile-name1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -640,6 +640,6 @@
|
||||
|
||||
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/app.js?v=track40-voice-name-edit1"></script>
|
||||
<script src="/operator/assets/app.js?v=track41-customer-profile-name1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+53
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user