Improve operator CRM flows and UI

This commit is contained in:
Yera All
2026-04-03 23:47:34 +05:00
parent 8e60ce0f39
commit 78918a38fd
10 changed files with 1446 additions and 118 deletions
+16
View File
@@ -157,6 +157,13 @@ SERVICE_URLS = {
} }
def _env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@app.get("/health", response_model=HealthResponse) @app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse: def health() -> HealthResponse:
return HealthResponse(status="ok", service="api-gateway") return HealthResponse(status="ok", service="api-gateway")
@@ -179,6 +186,15 @@ def registry() -> dict:
return {"services": SERVICE_URLS} return {"services": SERVICE_URLS}
@app.get("/operator/config")
def operator_ui_config() -> dict[str, Any]:
return {
"features": {
"whatsapp": _env_flag("OPERATOR_WHATSAPP_ENABLED", default=False),
}
}
@app.get("/contracts") @app.get("/contracts")
def contracts() -> dict: def contracts() -> dict:
return { return {
+483 -10
View File
@@ -1,24 +1,136 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from sqlalchemy import select from sqlalchemy import select
from services.shared.core import new_id, utc_now_iso from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session from services.shared.db import get_session
from services.shared.models import CustomerCreate, CustomerOut, HealthResponse from services.shared.models import (
CustomerCreate,
CustomerHistoryEventOut,
CustomerHistoryOut,
CustomerHistorySummaryOut,
CustomerOut,
HealthResponse,
InteractionOut,
TelegramThreadOut,
VoiceLiveCallOut,
)
from services.shared.sql_init import init_sql_schema from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import Customer from services.shared.sql_models import (
AsteriskCallLinkRow,
CallRecordingRow,
Customer,
Interaction,
InteractionTimeline,
TelegramMessageRow,
TelegramThreadRow,
VoiceTranscriptSegmentRow,
)
app = FastAPI(title="customer-service", version="1.0.0") app = FastAPI(title="customer-service", version="1.0.0")
init_sql_schema() init_sql_schema()
_CHANNEL_LABELS = {
"voice": "Голос",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"webchat": "Веб-чат",
"email": "Email",
}
_INTERACTION_STATUS_LABELS = {
"new": "новое",
"in_progress": "в работе",
"escalated": "эскалировано",
"closed": "закрыто",
"abandoned": "потеряно",
}
_TELEPHONY_STATUS_LABELS = {
"ringing": "Звонит",
"claimed": "Взято",
"connected": "Соединено",
"ended": "Завершено",
"failed": "Ошибка",
}
_TIMELINE_ACTION_TITLES = {
"interaction.created": "Обращение создано",
"interaction.assigned": "Обращение назначено",
"interaction.status_changed": "Статус обращения изменён",
"interaction.escalated": "Обращение эскалировано",
}
def _json_list(raw: str | None) -> list:
try:
value = json.loads(raw or "[]")
except json.JSONDecodeError:
return []
return value if isinstance(value, list) else []
def _json_dict(raw: str | None) -> dict:
try:
value = json.loads(raw or "{}")
except json.JSONDecodeError:
return {}
return value if isinstance(value, dict) else {}
def _time_value(value: str | None) -> int:
if not value:
return 0
normalized = value.strip()
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
try:
return int(datetime.fromisoformat(normalized).timestamp())
except ValueError:
return 0
def _channel_label(channel: str | None) -> str:
return _CHANNEL_LABELS.get(channel or "", channel or "Канал")
def _interaction_status_label(status: str | None) -> str:
return _INTERACTION_STATUS_LABELS.get(status or "", status or "неизвестно")
def _telephony_status_label(status: str | None) -> str:
return _TELEPHONY_STATUS_LABELS.get(status or "", status or "неизвестно")
def _timeline_title(action: str) -> str:
return _TIMELINE_ACTION_TITLES.get(action, action)
def _timeline_body(action: str, metadata: dict) -> str:
if action == "interaction.assigned":
assignee = metadata.get("assignee")
return f"Обращение назначено на {assignee}." if assignee else "Обращение назначено оператору."
if action == "interaction.status_changed":
status = metadata.get("status")
return f"Новый статус: {_interaction_status_label(status)}."
if action == "interaction.escalated":
target_queue_id = metadata.get("target_queue_id")
return (
f"Обращение передано в очередь {target_queue_id}."
if target_queue_id
else "Обращение эскалировано."
)
if action == "interaction.created":
channel = metadata.get("channel")
return f"Создано новое обращение по каналу {_channel_label(channel)}."
return "Событие сохранено в ленте обращения."
def _to_out(row: Customer) -> CustomerOut: def _to_out(row: Customer) -> CustomerOut:
phones = json.loads(row.phones_json or "[]") phones = _json_list(row.phones_json)
tags = json.loads(row.tags_json or "[]") tags = _json_list(row.tags_json)
return CustomerOut( return CustomerOut(
customer_id=row.customer_id, customer_id=row.customer_id,
display_name=row.display_name, display_name=row.display_name,
@@ -29,6 +141,296 @@ def _to_out(row: Customer) -> CustomerOut:
) )
def _interaction_to_out(row: Interaction) -> InteractionOut:
return InteractionOut(
interaction_id=row.interaction_id,
channel=row.channel,
subject=row.subject,
customer_id=row.customer_id,
queue_id=row.queue_id,
priority=row.priority,
status=row.status,
assigned_to=row.assigned_to,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _telegram_thread_to_out(row: TelegramThreadRow) -> TelegramThreadOut:
return TelegramThreadOut(
thread_id=row.thread_id,
chat_id=row.chat_id,
interaction_id=row.interaction_id,
telegram_user_id=row.telegram_user_id,
username=row.username,
display_name=row.display_name,
queue_id=row.queue_id,
status=row.status, # type: ignore[arg-type]
claimed_by_user=row.claimed_by_user,
claimed_at=row.claimed_at,
ai_session_id=row.ai_session_id,
ai_state=row.ai_state, # type: ignore[arg-type]
ai_handoff_reason=row.ai_handoff_reason,
ai_last_model_at=row.ai_last_model_at,
last_message_at=row.last_message_at,
last_message_preview=row.last_message_preview,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _voice_call_to_out(row: AsteriskCallLinkRow, *, has_recording: bool) -> VoiceLiveCallOut:
return VoiceLiveCallOut(
call_id=row.call_id,
interaction_id=row.interaction_id,
queue_id=row.queue_id,
queue_code=row.queue_code,
caller_number=row.caller_number,
caller_name=row.caller_name,
status=row.status,
telephony_status=row.telephony_status, # type: ignore[arg-type]
claimed_by_user=row.claimed_by_user,
claimed_at=row.claimed_at,
operator_extension=row.operator_extension,
channel_name=row.channel_name,
started_at=row.started_at,
connected_at=row.connected_at,
ended_at=row.ended_at,
updated_at=row.updated_at,
last_transition_at=row.ended_at or row.connected_at or row.updated_at,
hangup_cause=None,
terminal_action=None,
terminal_target=None,
voice_session_id=row.voice_session_id,
ai_session_id=row.ai_session_id,
ai_state=row.ai_state, # type: ignore[arg-type]
ai_handoff_reason=row.ai_handoff_reason,
ai_last_model_at=row.ai_last_model_at,
has_recording=has_recording,
)
def _history_event_sort_key(event: CustomerHistoryEventOut) -> tuple[int, str]:
return (_time_value(event.timestamp), event.note)
def _build_customer_history(
customer: Customer,
interactions: list[Interaction],
timelines: list[InteractionTimeline],
telegram_threads: list[TelegramThreadRow],
telegram_messages: list[TelegramMessageRow],
live_calls: list[AsteriskCallLinkRow],
transcript_segments: list[VoiceTranscriptSegmentRow],
recording_call_ids: set[str],
) -> CustomerHistoryOut:
customer_out = _to_out(customer)
interaction_out = [_interaction_to_out(row) for row in interactions]
telegram_thread_out = [_telegram_thread_to_out(row) for row in telegram_threads]
live_call_out = [_voice_call_to_out(row, has_recording=row.call_id in recording_call_ids) for row in live_calls]
interaction_by_id = {row.interaction_id: row for row in interactions}
thread_by_id = {row.thread_id: row for row in telegram_threads}
events: list[CustomerHistoryEventOut] = [
CustomerHistoryEventOut(
timestamp=customer.created_at,
kind="profile",
title="Профиль клиента активен",
body=(
f"Контакт доступен по номеру {customer_out.phones[0]}."
if customer_out.phones
else "Контакт добавлен в клиентскую базу."
),
note="".join(filter(None, [customer.customer_id, customer_out.preferred_phone])),
)
]
for row in interactions:
events.append(
CustomerHistoryEventOut(
timestamp=row.updated_at or row.created_at,
kind=row.channel or "case",
title=f"{_channel_label(row.channel)}{_interaction_status_label(row.status)}",
body=row.subject or "Обращение без темы",
note="".join(
filter(
None,
[
row.interaction_id,
f"оператор {row.assigned_to}" if row.assigned_to else "",
f"queue {row.queue_id}" if row.queue_id else "",
],
)
),
interaction_id=row.interaction_id,
)
)
for row in timelines:
interaction = interaction_by_id.get(row.interaction_id)
metadata = _json_dict(row.metadata_json)
events.append(
CustomerHistoryEventOut(
timestamp=row.timestamp,
kind=interaction.channel if interaction else "case",
title=_timeline_title(row.action),
body=_timeline_body(row.action, metadata),
note="".join(
filter(
None,
[
row.interaction_id,
f"оператор {interaction.assigned_to}" if interaction and interaction.assigned_to else "",
],
)
),
interaction_id=row.interaction_id,
)
)
for row in telegram_threads:
events.append(
CustomerHistoryEventOut(
timestamp=row.last_message_at or row.updated_at or row.created_at,
kind="telegram",
title="Telegram диалог",
body=row.last_message_preview or "Последнее сообщение недоступно.",
note="".join(
filter(
None,
[
row.display_name or row.username or f"chat {row.chat_id}",
f"оператор {row.claimed_by_user}" if row.claimed_by_user else "",
_interaction_status_label(row.status),
],
)
),
interaction_id=row.interaction_id,
thread_id=row.thread_id,
)
)
for row in telegram_messages:
thread = thread_by_id.get(row.thread_id or "")
direction = {
"outbound": "Исходящее сообщение",
"system": "Системное сообщение",
}.get(row.direction, "Входящее сообщение")
events.append(
CustomerHistoryEventOut(
timestamp=row.created_at,
kind="telegram",
title=direction,
body=row.text or "Сообщение без текста",
note="".join(
filter(
None,
[
thread.display_name or thread.username if thread else "",
f"оператор {row.operator_user}" if row.operator_user else "",
row.delivery_status or "",
],
)
),
interaction_id=row.interaction_id,
thread_id=row.thread_id,
)
)
for row in live_calls:
completed = row.telephony_status == "ended" or bool(row.ended_at)
events.append(
CustomerHistoryEventOut(
timestamp=row.ended_at or row.connected_at or row.updated_at or row.started_at,
kind="voice",
title=(
f"Звонок • {_telephony_status_label('ended')}"
if completed
else f"Звонок • {_telephony_status_label(row.telephony_status)}"
),
body=f"Номер клиента: {row.caller_number or 'не определён'}",
note="".join(
filter(
None,
[
row.call_id,
f"внутр. {row.operator_extension}" if row.operator_extension else "",
f"оператор {row.claimed_by_user}" if row.claimed_by_user else "",
],
)
),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
if row.ai_state or row.ai_handoff_reason:
events.append(
CustomerHistoryEventOut(
timestamp=row.ai_last_model_at or row.updated_at or row.started_at,
kind="voice",
title=f"Голосовой AI • {row.ai_state or 'active'}",
body=row.ai_handoff_reason or "AI обновил контекст разговора для оператора.",
note="".join(
filter(
None,
[
row.call_id,
f"voice {row.voice_session_id}" if row.voice_session_id else "",
f"ai {row.ai_session_id}" if row.ai_session_id else "",
],
)
),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
for row in transcript_segments:
speaker = "AI" if row.speaker == "assistant" else "Клиент"
events.append(
CustomerHistoryEventOut(
timestamp=row.created_at,
kind="voice",
title=f"Транскрипт • {speaker}",
body=row.text or "Реплика без текста",
note="".join(filter(None, [row.call_id, f"seq {row.sequence_no}"])),
interaction_id=row.interaction_id,
call_id=row.call_id,
)
)
events.sort(key=_history_event_sort_key, reverse=True)
active_channels = sorted(
{
*[row.channel for row in interactions if row.channel],
*(["telegram"] if telegram_threads else []),
*(["voice"] if live_calls else []),
}
)
latest_event = events[0] if events else None
primary_thread = max(
telegram_threads,
key=lambda row: _time_value(row.last_message_at or row.updated_at or row.created_at),
default=None,
)
summary = CustomerHistorySummaryOut(
contact_points=len(interactions) + len(telegram_threads) + len(live_calls),
open_cases=sum(1 for row in interactions if row.status != "closed"),
active_channels=active_channels,
latest_event_at=latest_event.timestamp if latest_event else None,
latest_event_title=latest_event.title if latest_event else None,
primary_phone=customer_out.phones[0] if customer_out.phones else customer_out.preferred_phone,
primary_telegram_thread_id=primary_thread.thread_id if primary_thread else None,
)
return CustomerHistoryOut(
customer=customer_out,
summary=summary,
interactions=interaction_out,
telegram_threads=telegram_thread_out,
live_calls=live_call_out,
history=events[:25],
)
@app.get("/health", response_model=HealthResponse) @app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse: def health() -> HealthResponse:
return HealthResponse(status="ok", service="customer-service") return HealthResponse(status="ok", service="customer-service")
@@ -89,13 +491,84 @@ def search_customers(query: str | None = None, limit: int = 100) -> list[Custome
session.close() session.close()
@app.get("/customers/{customer_id}/history") @app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut)
def customer_history(customer_id: str) -> dict: def customer_history(customer_id: str) -> CustomerHistoryOut:
session = get_session() session = get_session()
try: try:
row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none() customer = session.execute(
if not row: select(Customer).where(Customer.customer_id == customer_id)
).scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
return {"customer_id": customer_id, "interactions": []}
interactions = session.execute(
select(Interaction)
.where(Interaction.customer_id == customer_id)
.order_by(Interaction.updated_at.desc(), Interaction.id.desc())
).scalars().all()
interaction_ids = [row.interaction_id for row in interactions]
timelines: list[InteractionTimeline] = []
telegram_threads: list[TelegramThreadRow] = []
telegram_messages: list[TelegramMessageRow] = []
live_calls: list[AsteriskCallLinkRow] = []
transcript_segments: list[VoiceTranscriptSegmentRow] = []
recording_call_ids: set[str] = set()
if interaction_ids:
timelines = session.execute(
select(InteractionTimeline)
.where(InteractionTimeline.interaction_id.in_(interaction_ids))
.order_by(InteractionTimeline.timestamp.desc(), InteractionTimeline.id.desc())
).scalars().all()
telegram_threads = session.execute(
select(TelegramThreadRow)
.where(TelegramThreadRow.interaction_id.in_(interaction_ids))
.order_by(TelegramThreadRow.last_message_at.desc(), TelegramThreadRow.id.desc())
).scalars().all()
live_calls = session.execute(
select(AsteriskCallLinkRow)
.where(AsteriskCallLinkRow.interaction_id.in_(interaction_ids))
.order_by(AsteriskCallLinkRow.updated_at.desc(), AsteriskCallLinkRow.id.desc())
).scalars().all()
thread_ids = [row.thread_id for row in telegram_threads if row.thread_id]
if thread_ids:
telegram_messages = session.execute(
select(TelegramMessageRow)
.where(TelegramMessageRow.thread_id.in_(thread_ids))
.order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc())
.limit(8)
).scalars().all()
call_ids = [row.call_id for row in live_calls if row.call_id]
if call_ids:
recording_call_ids = set(
session.execute(
select(CallRecordingRow.call_id).where(CallRecordingRow.call_id.in_(call_ids))
).scalars().all()
)
transcript_segments = session.execute(
select(VoiceTranscriptSegmentRow)
.where(
VoiceTranscriptSegmentRow.call_id.in_(call_ids),
VoiceTranscriptSegmentRow.is_final.is_(True),
)
.order_by(VoiceTranscriptSegmentRow.created_at.desc(), VoiceTranscriptSegmentRow.id.desc())
.limit(8)
).scalars().all()
return _build_customer_history(
customer,
interactions,
timelines,
telegram_threads,
telegram_messages,
live_calls,
transcript_segments,
recording_call_ids,
)
finally: finally:
session.close() session.close()
+30
View File
@@ -96,6 +96,36 @@ class CustomerOut(CustomerCreate):
created_at: str created_at: str
class CustomerHistoryEventOut(BaseModel):
timestamp: str
kind: str
title: str
body: str
note: str = ""
interaction_id: str | None = None
thread_id: str | None = None
call_id: str | None = None
class CustomerHistorySummaryOut(BaseModel):
contact_points: int = 0
open_cases: int = 0
active_channels: list[str] = Field(default_factory=list)
latest_event_at: str | None = None
latest_event_title: str | None = None
primary_phone: str | None = None
primary_telegram_thread_id: str | None = None
class CustomerHistoryOut(BaseModel):
customer: CustomerOut
summary: CustomerHistorySummaryOut
interactions: list["InteractionOut"] = Field(default_factory=list)
telegram_threads: list["TelegramThreadOut"] = Field(default_factory=list)
live_calls: list["VoiceLiveCallOut"] = Field(default_factory=list)
history: list[CustomerHistoryEventOut] = Field(default_factory=list)
class InteractionCreate(BaseModel): class InteractionCreate(BaseModel):
channel: Channel channel: Channel
subject: str = Field(min_length=3) subject: str = Field(min_length=3)
+60 -3
View File
@@ -14,6 +14,13 @@ def test_operator_ui_route_exists():
assert 'id="browserPhoneCallOverlay"' in response.text assert 'id="browserPhoneCallOverlay"' in response.text
def test_operator_ui_config_disables_whatsapp_by_default():
client = TestClient(app)
response = client.get("/operator/config")
assert response.status_code == 200
assert response.json()["features"]["whatsapp"] is False
def test_login_ui_route_exists(): def test_login_ui_route_exists():
client = TestClient(app) client = TestClient(app)
response = client.get("/") response = client.get("/")
@@ -132,6 +139,36 @@ def test_operator_ui_keeps_webchat_channel_option_without_demo_block():
assert "function sendWebchatMessage()" not in app_js assert "function sendWebchatMessage()" not in app_js
def test_operator_ui_contains_unified_inbox_panel():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
assert 'id="unifiedInbox"' in index_html
assert 'id="refreshUnifiedInboxBtn"' in index_html
assert 'id="unifiedInboxSummary"' in index_html
assert 'id="unifiedInboxBoard"' in index_html
assert "const UNIFIED_INBOX_COLUMNS = [" in app_js
assert "function buildUnifiedInboxCollections()" in app_js
assert "function renderUnifiedInbox()" in app_js
assert "function focusLiveCall(callId)" in app_js
assert "function openUnifiedInboxItem(button)" in app_js
assert "function handleUnifiedInboxClick(event)" in app_js
assert "function refreshUnifiedInbox()" in app_js
assert "window.location.hash = '#telegram-page';" in app_js
assert "window.location.hash = '#calls';" in app_js
assert "window.location.hash = '#interactions';" in app_js
def test_operator_ui_loads_backend_customer_history_for_profile():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
assert "historyById: {}" in app_js
assert "pendingHistoryById: {}" in app_js
assert "function customerHistoryPayload(customerId)" in app_js
assert "function ensureCustomerHistoryLoaded(customerId, options = {})" in app_js
assert "api('customer', `customers/${encodeURIComponent(customerId)}/history`)" in app_js
assert "const historyPayload = customerHistoryPayload(customer.customer_id);" in app_js
assert "ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});" in app_js
def test_operator_ui_contains_leads_table_front(): def test_operator_ui_contains_leads_table_front():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
@@ -186,9 +223,13 @@ def test_operator_ui_contains_live_call_control_block():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
assert 'id="liveCalls"' in index_html assert 'id="liveCalls"' in index_html
assert 'data-operator-view="voice-debug"' in index_html assert 'id="callsView"' in index_html
assert 'data-operator-view-link="voice-debug"' in index_html assert 'data-operator-view="calls"' in index_html
assert 'href="#voice-debug"' in index_html assert 'data-operator-view-link="calls"' in index_html
assert 'href="#calls"' in index_html
assert 'data-operator-view="voice-debug"' not in index_html
assert 'data-operator-view-link="voice-debug"' not in index_html
assert 'href="#voice-debug"' not in index_html
assert 'id="loadLiveCallsBtn"' in index_html assert 'id="loadLiveCallsBtn"' in index_html
assert 'id="loadLiveCallActionsBtn"' in index_html assert 'id="loadLiveCallActionsBtn"' in index_html
assert 'id="liveCallIdSelect"' in index_html assert 'id="liveCallIdSelect"' in index_html
@@ -204,6 +245,8 @@ def test_operator_ui_contains_live_call_control_block():
assert "Только что завершённые" in app_js assert "Только что завершённые" in app_js
assert "function terminalActionLabel" in app_js assert "function terminalActionLabel" in app_js
assert "function fallbackTelephonyStatus" not in app_js assert "function fallbackTelephonyStatus" not in app_js
assert "raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page'" in app_js
assert "window.history.replaceState(null, '', '#calls');" in app_js
assert 'data-operator-anchor-link="liveCalls"' not in index_html assert 'data-operator-anchor-link="liveCalls"' not in index_html
assert 'data-operator-anchor-link="interactions"' not in index_html assert 'data-operator-anchor-link="interactions"' not in index_html
assert 'data-operator-anchor-link="integrations"' not in index_html assert 'data-operator-anchor-link="integrations"' not in index_html
@@ -243,6 +286,20 @@ def test_operator_ui_contains_browser_softphone_popup_controls():
assert "function stopBrowserPhoneRingtone" in app_js assert "function stopBrowserPhoneRingtone" in app_js
def test_operator_ui_hides_whatsapp_behind_feature_flag():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
assert 'data-operator-view-link="whatsapp" data-feature="whatsapp"' in index_html
assert 'data-feature-option="whatsapp"' in index_html
assert 'data-operator-view="whatsapp" data-feature="whatsapp" hidden' in index_html
assert "function loadOperatorConfig()" in app_js
assert "const response = await fetch('/operator/config');" in app_js
assert "state.features.whatsapp = Boolean(data?.features?.whatsapp);" in app_js
assert "document.querySelectorAll('[data-feature=\"whatsapp\"]')" in app_js
assert "document.querySelectorAll('[data-feature-option=\"whatsapp\"]')" in app_js
assert "if (featureEnabled('whatsapp')) {" in app_js
def test_operator_ui_browser_softphone_has_fast_answer_and_hangup_states(): def test_operator_ui_browser_softphone_has_fast_answer_and_hangup_states():
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
assert "function browserPhonePopupPhase" in app_js assert "function browserPhonePopupPhase" in app_js
+193 -1
View File
@@ -5,7 +5,14 @@ from services.interaction_service.app import app as interaction_app
from services.ivr_service.app import app as ivr_app from services.ivr_service.app import app as ivr_app
from services.routing_service.app import app as routing_app from services.routing_service.app import app as routing_app
from services.shared.db import get_session from services.shared.db import get_session
from services.shared.sql_models import Queue from services.shared.sql_models import (
AsteriskCallLinkRow,
CallRecordingRow,
Queue,
TelegramMessageRow,
TelegramThreadRow,
VoiceTranscriptSegmentRow,
)
def test_customer_and_interaction_lifecycle(): def test_customer_and_interaction_lifecycle():
@@ -62,6 +69,191 @@ def test_customer_and_interaction_lifecycle():
assert len(timeline.json()["events"]) >= 4 assert len(timeline.json()["events"]) >= 4
def test_customer_history_aggregates_telegram_and_voice_context():
customer_client = TestClient(customer_app)
interaction_client = TestClient(interaction_app)
customer = customer_client.post(
"/customers",
json={
"display_name": "History Client",
"phones": ["+77010000019"],
"preferred_phone": "+77010000019",
"tags": ["priority"],
},
)
assert customer.status_code == 200
customer_id = customer.json()["customer_id"]
voice_interaction = interaction_client.post(
"/interactions",
json={
"channel": "voice",
"subject": "Need callback after AI handoff",
"customer_id": customer_id,
"queue_id": "q_voice",
"priority": 2,
},
headers={"X-User": "operator", "X-Role": "operator"},
)
assert voice_interaction.status_code == 200
voice_interaction_id = voice_interaction.json()["interaction_id"]
telegram_interaction = interaction_client.post(
"/interactions",
json={
"channel": "telegram",
"subject": "Order issue in Telegram",
"customer_id": customer_id,
"queue_id": "q_chat",
"priority": 3,
},
headers={"X-User": "operator", "X-Role": "operator"},
)
assert telegram_interaction.status_code == 200
telegram_interaction_id = telegram_interaction.json()["interaction_id"]
assigned = interaction_client.patch(
f"/interactions/{telegram_interaction_id}/assign",
json={"assignee": "operator_a"},
headers={"X-User": "supervisor", "X-Role": "supervisor"},
)
assert assigned.status_code == 200
session = get_session()
try:
session.add(
TelegramThreadRow(
thread_id="tgt_history_customer",
chat_id="chat_history_customer",
interaction_id=telegram_interaction_id,
telegram_user_id="tg_hist_1",
username="history_client",
display_name="History Client",
queue_id="q_chat",
status="in_progress",
claimed_by_user="operator_a",
claimed_at="2026-04-03T09:05:00+00:00",
ai_session_id="ais_hist_1",
ai_state="human_owned",
ai_handoff_reason="Нужно проверить статус заказа вручную",
ai_last_model_at="2026-04-03T09:04:00+00:00",
last_message_at="2026-04-03T09:06:00+00:00",
last_message_preview="Клиент просит уточнить статус заказа",
created_at="2026-04-03T09:00:00+00:00",
updated_at="2026-04-03T09:06:00+00:00",
)
)
session.add(
TelegramMessageRow(
message_id="tgm_history_customer_in",
thread_id="tgt_history_customer",
interaction_id=telegram_interaction_id,
chat_id="chat_history_customer",
text="Клиент просит уточнить статус заказа",
customer_external_id="telegram:history_client",
direction="inbound",
telegram_message_id_external="501",
operator_user=None,
author_type="customer",
author_id="tg_hist_1",
delivery_status=None,
payload_json="{}",
created_at="2026-04-03T09:06:00+00:00",
)
)
session.add(
AsteriskCallLinkRow(
call_id="call_history_customer",
linked_id="linked_history_customer",
queue_code="voice_support",
queue_id="q_voice",
interaction_id=voice_interaction_id,
caller_number="+77010000019",
caller_name="History Client",
status="active",
telephony_status="connected",
claimed_by_user="operator_a",
claimed_at="2026-04-03T09:11:00+00:00",
operator_extension="2001",
channel_name="PJSIP/2001-000001",
voice_session_id="vas_history_1",
ai_session_id="ais_voice_1",
ai_state="handoff_required",
ai_handoff_reason="AI не смог подтвердить оплату по заказу",
ai_last_model_at="2026-04-03T09:12:00+00:00",
started_at="2026-04-03T09:10:00+00:00",
connected_at="2026-04-03T09:11:00+00:00",
ended_at=None,
updated_at="2026-04-03T09:12:00+00:00",
)
)
session.add(
CallRecordingRow(
recording_id="rec_history_customer",
channel="voice",
call_id="call_history_customer",
interaction_id=voice_interaction_id,
source_event_id="evt_history_customer",
file_name="history-call.wav",
storage_backend="local_fs",
storage_path="history/history-call.wav",
mime_type="audio/wav",
size_bytes=1024,
duration_seconds=48,
checksum_sha256="abc123",
status="ready",
recorded_at="2026-04-03T09:13:00+00:00",
created_at="2026-04-03T09:13:00+00:00",
updated_at="2026-04-03T09:13:00+00:00",
archived_at=None,
)
)
session.add(
VoiceTranscriptSegmentRow(
segment_id="seg_history_customer",
session_id="vas_history_1",
call_id="call_history_customer",
interaction_id=voice_interaction_id,
speaker="caller",
source_type="asr",
sequence_no=1,
text="Мне нужно, чтобы оператор подтвердил оплату по счёту",
confidence=0.93,
is_final=True,
barge_in_interrupted=False,
payload_json="{}",
created_at="2026-04-03T09:11:30+00:00",
)
)
session.commit()
finally:
session.close()
history = customer_client.get(f"/customers/{customer_id}/history")
assert history.status_code == 200
payload = history.json()
assert payload["customer"]["customer_id"] == customer_id
assert payload["summary"]["open_cases"] == 2
assert payload["summary"]["primary_phone"] == "+77010000019"
assert payload["summary"]["primary_telegram_thread_id"] == "tgt_history_customer"
assert set(payload["summary"]["active_channels"]) == {"telegram", "voice"}
assert {item["interaction_id"] for item in payload["interactions"]} == {
voice_interaction_id,
telegram_interaction_id,
}
assert payload["telegram_threads"][0]["thread_id"] == "tgt_history_customer"
assert payload["live_calls"][0]["call_id"] == "call_history_customer"
assert payload["live_calls"][0]["has_recording"] is True
titles = [item["title"] for item in payload["history"]]
assert "Telegram диалог" in titles
assert "Обращение назначено" in titles
assert any(title.startswith("Звонок •") for title in titles)
assert any(item["body"] == "Клиент просит уточнить статус заказа" for item in payload["history"])
assert any("оператор" in (item["note"] or "") for item in payload["history"])
def test_queue_rules_and_route(): def test_queue_rules_and_route():
routing_client = TestClient(routing_app) routing_client = TestClient(routing_app)
+3 -2
View File
@@ -75,8 +75,9 @@ def test_manual_webhook_creates_thread_and_linked_interaction():
thread_list = telegram_client.get("/integrations/telegram/threads", headers=admin_headers()) thread_list = telegram_client.get("/integrations/telegram/threads", headers=admin_headers())
assert thread_list.status_code == 200 assert thread_list.status_code == 200
assert thread_list.json()[0]["chat_id"] == "chat_1" listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"])
assert thread_list.json()[0]["interaction_id"] == payload["interaction_id"] assert listed_thread["chat_id"] == "chat_1"
assert listed_thread["interaction_id"] == payload["interaction_id"]
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
assert interaction.status_code == 200 assert interaction.status_code == 200
+4 -3
View File
@@ -85,9 +85,10 @@ def test_manual_webhook_creates_thread_and_linked_interaction():
thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers()) thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers())
assert thread_list.status_code == 200 assert thread_list.status_code == 200
assert thread_list.json()[0]["chat_id"] == "wa_chat_1" listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"])
assert thread_list.json()[0]["phone_number"] == "+77015550001" assert listed_thread["chat_id"] == "wa_chat_1"
assert thread_list.json()[0]["unread_count"] == 1 assert listed_thread["phone_number"] == "+77015550001"
assert listed_thread["unread_count"] == 1
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
assert interaction.status_code == 200 assert interaction.status_code == 200
+601 -86
View File
@@ -4,6 +4,9 @@
token: null, token: null,
authSource: 'local', authSource: 'local',
fullName: null, fullName: null,
features: {
whatsapp: false,
},
logLines: [], logLines: [],
interactions: [], interactions: [],
oidc: { oidc: {
@@ -26,6 +29,9 @@
pageSize: 7, pageSize: 7,
selectedCustomerId: '', selectedCustomerId: '',
leadFormOpen: false, leadFormOpen: false,
historyById: {},
pendingHistoryById: {},
historyErrorsById: {},
}, },
telegram: { telegram: {
threads: [], threads: [],
@@ -346,6 +352,14 @@ const BOARD_COLUMNS = [
{ key: 'closed', title: 'Закрытые' }, { key: 'closed', title: 'Закрытые' },
]; ];
const UNIFIED_INBOX_COLUMNS = [
{ key: 'new', title: 'Новые', emptyMessage: 'Новых задач сейчас нет.' },
{ key: 'mine', title: 'Мои', emptyMessage: 'За вами пока ничего не закреплено.' },
{ key: 'ai', title: 'AI handoff', emptyMessage: 'Передач от AI сейчас нет.' },
{ key: 'escalated', title: 'Эскалации', emptyMessage: 'Эскалаций сейчас нет.' },
{ key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' },
];
const LIVE_TELEPHONY_LABELS = { const LIVE_TELEPHONY_LABELS = {
ringing: 'Звонит', ringing: 'Звонит',
claimed: 'Взято', claimed: 'Взято',
@@ -373,9 +387,62 @@ const OPERATOR_VIEW_IDS = {
'customer-profile': 'customerProfileView', 'customer-profile': 'customerProfileView',
telegram: 'telegramView', telegram: 'telegramView',
whatsapp: 'whatsappView', whatsapp: 'whatsappView',
'voice-debug': 'voiceDebugView', calls: 'callsView',
}; };
function featureEnabled(feature) {
return Boolean(state.features?.[feature]);
}
function isOperatorViewEnabled(view) {
if (view === 'whatsapp') {
return featureEnabled('whatsapp');
}
return true;
}
function stopWhatsappPolling() {
if (state.whatsapp.pollTimer) {
window.clearInterval(state.whatsapp.pollTimer);
state.whatsapp.pollTimer = null;
}
}
function resetWhatsappState(mode = 'idle') {
state.whatsapp.chats = [];
state.whatsapp.selectedChatId = '';
state.whatsapp.searchQuery = '';
state.whatsapp.activeFilter = 'all';
state.whatsapp.composerText = '';
state.whatsapp.selectedThreadSummary = null;
state.whatsapp.pendingAction = '';
state.whatsapp.backendError = '';
state.whatsapp.mockFallbackLogged = false;
state.whatsapp.mode = mode;
}
function syncWhatsappFeatureVisibility() {
const enabled = featureEnabled('whatsapp');
document.querySelectorAll('[data-feature="whatsapp"]').forEach((element) => {
element.hidden = !enabled;
});
document.querySelectorAll('[data-feature-option="whatsapp"]').forEach((element) => {
element.hidden = !enabled;
element.disabled = !enabled;
});
if (!enabled) {
stopWhatsappPolling();
resetWhatsappState('disabled');
if ($('interactionChannel')?.value === 'whatsapp') {
$('interactionChannel').value = 'voice';
}
return;
}
if (state.whatsapp.mode === 'disabled') {
resetWhatsappState('idle');
}
}
function syncSessionFromInputs() { function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'admin'; state.user = $('sessionUser').value.trim() || 'admin';
state.role = $('sessionRole').value || 'admin'; state.role = $('sessionRole').value || 'admin';
@@ -469,6 +536,7 @@ function logout() {
window.clearInterval(state.telegram.pollTimer); window.clearInterval(state.telegram.pollTimer);
state.telegram.pollTimer = null; state.telegram.pollTimer = null;
} }
stopWhatsappPolling();
clearStoredSession(); clearStoredSession();
window.location.href = '/'; window.location.href = '/';
} }
@@ -612,9 +680,26 @@ function customerProfileHash(customerId = state.customers.selectedCustomerId) {
function openCustomerProfile(customerId) { function openCustomerProfile(customerId) {
selectCustomer(customerId || ''); selectCustomer(customerId || '');
ensureCustomerHistoryLoaded(customerId || state.customers.selectedCustomerId, { force: true }).catch(() => {});
window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId); window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId);
} }
function interactionById(interactionId) {
return state.interactions.find((item) => item.interaction_id === interactionId) || null;
}
function customerIdForInteractionId(interactionId) {
return interactionById(interactionId)?.customer_id || '';
}
function customerDisplayName(customerId) {
if (!customerId) {
return 'не привязан';
}
const customer = state.customers.items.find((item) => item.customer_id === customerId) || null;
return customer?.display_name || customerId;
}
function customerIndex(customer) { function customerIndex(customer) {
if (!customer) { if (!customer) {
return -1; return -1;
@@ -663,6 +748,43 @@ function customerLiveCalls(customer) {
}); });
} }
function customerHistoryPayload(customerId) {
if (!customerId) {
return null;
}
return state.customers.historyById[customerId] || null;
}
function customerHistoryPending(customerId) {
return Boolean(customerId && state.customers.pendingHistoryById[customerId]);
}
async function ensureCustomerHistoryLoaded(customerId, options = {}) {
const { force = false } = options;
if (!customerId) {
return null;
}
if (customerHistoryPending(customerId)) {
return customerHistoryPayload(customerId);
}
if (!force && customerHistoryPayload(customerId)) {
return customerHistoryPayload(customerId);
}
state.customers.pendingHistoryById[customerId] = true;
delete state.customers.historyErrorsById[customerId];
try {
const data = await api('customer', `customers/${encodeURIComponent(customerId)}/history`);
state.customers.historyById[customerId] = data || null;
return state.customers.historyById[customerId];
} catch (err) {
state.customers.historyErrorsById[customerId] = err.message;
return null;
} finally {
delete state.customers.pendingHistoryById[customerId];
renderCustomerProfilePage();
}
}
function customerPreferredChannel(customer) { function customerPreferredChannel(customer) {
const counters = new Map(); const counters = new Map();
customerInteractions(customer).forEach((item) => { customerInteractions(customer).forEach((item) => {
@@ -835,23 +957,48 @@ function customerSpotlightMarkup(customer, options = {}) {
const source = customerLeadSourceMeta(customer, index); const source = customerLeadSourceMeta(customer, index);
const leadStatus = customerLeadStatusMeta(customer, index); const leadStatus = customerLeadStatusMeta(customer, index);
const score = customerLeadScore(customer, index); const score = customerLeadScore(customer, index);
const interactions = customerInteractions(customer); const historyPayload = customerHistoryPayload(customer.customer_id);
const threads = customerTelegramThreads(customer); const historySummary = historyPayload?.summary || null;
const liveCalls = customerLiveCalls(customer); const interactions = Array.isArray(historyPayload?.interactions)
const historyEvents = buildCustomerHistoryEvents(customer); ? historyPayload.interactions
: customerInteractions(customer);
const threads = Array.isArray(historyPayload?.telegram_threads)
? historyPayload.telegram_threads
: customerTelegramThreads(customer);
const liveCalls = Array.isArray(historyPayload?.live_calls)
? historyPayload.live_calls
: customerLiveCalls(customer);
const historyEvents = Array.isArray(historyPayload?.history) && historyPayload.history.length
? historyPayload.history
: buildCustomerHistoryEvents(customer);
const latestEvent = historyEvents[0] || null; const latestEvent = historyEvents[0] || null;
const openCases = interactions.filter((item) => item.status !== 'closed').length; const openCases = Number.isFinite(Number(historySummary?.open_cases))
const activeChannels = [...new Set([ ? Number(historySummary.open_cases)
...interactions.map((item) => item.channel).filter(Boolean), : interactions.filter((item) => item.status !== 'closed').length;
...(threads.length ? ['telegram'] : []), const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length
...(liveCalls.length ? ['voice'] : []), ? historySummary.active_channels
])]; : [...new Set([
...interactions.map((item) => item.channel).filter(Boolean),
...(threads.length ? ['telegram'] : []),
...(liveCalls.length ? ['voice'] : []),
])];
const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : []; const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : [];
const primaryThread = [...threads].sort((left, right) => { const primaryThread = historySummary?.primary_telegram_thread_id
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at); ? threads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null
})[0] || null; : [...threads].sort((left, right) => {
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at);
})[0] || null;
const historyStatusText = customerHistoryPending(customer.customer_id)
? 'Обновляем ленту клиента из backend...'
: state.customers.historyErrorsById[customer.customer_id]
? 'Показываем локальную историю, пока backend недоступен.'
: (latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения');
const summaryCards = [ const summaryCards = [
renderSummaryCard('Контакты', String(interactions.length + threads.length + liveCalls.length), 'Все точки касания клиента'), renderSummaryCard(
'Контакты',
String(Number.isFinite(Number(historySummary?.contact_points)) ? Number(historySummary.contact_points) : (interactions.length + threads.length + liveCalls.length)),
'Все точки касания клиента',
),
renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'), renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'),
renderSummaryCard( renderSummaryCard(
'Каналы', 'Каналы',
@@ -860,8 +1007,8 @@ function customerSpotlightMarkup(customer, options = {}) {
), ),
renderSummaryCard( renderSummaryCard(
'Последний контакт', 'Последний контакт',
latestEvent ? formatIsoShort(latestEvent.timestamp) : '—', historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'),
latestEvent ? latestEvent.title : 'Активность ещё не зафиксирована', historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована',
), ),
].join(''); ].join('');
@@ -908,7 +1055,7 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-meta-grid"> <div class="customer-profile-meta-grid">
<article class="customer-meta-card"> <article class="customer-meta-card">
<div class="customer-meta-label">Основной номер</div> <div class="customer-meta-label">Основной номер</div>
<div class="customer-meta-value">${escapeHtml(phones[0] || 'Не указан')}</div> <div class="customer-meta-value">${escapeHtml(historySummary?.primary_phone || phones[0] || 'Не указан')}</div>
<div class="customer-meta-note">${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}</div> <div class="customer-meta-note">${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}</div>
</article> </article>
<article class="customer-meta-card"> <article class="customer-meta-card">
@@ -942,7 +1089,7 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-kicker">Единая история клиента</div> <div class="customer-profile-kicker">Единая история клиента</div>
<h3>Все контакты в одной ленте</h3> <h3>Все контакты в одной ленте</h3>
</div> </div>
<div class="customer-history-caption">${escapeHtml(latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения')}</div> <div class="customer-history-caption">${escapeHtml(historyStatusText)}</div>
</div> </div>
<div class="customer-history-list"> <div class="customer-history-list">
${historyMarkup} ${historyMarkup}
@@ -994,10 +1141,13 @@ function renderCustomerProfilePage() {
if (title) { if (title) {
title.textContent = customer.display_name || 'Профиль клиента'; title.textContent = customer.display_name || 'Профиль клиента';
} }
const historyPayload = customerHistoryPayload(customer.customer_id);
ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});
if (hint) { if (hint) {
const phones = customerPhones(customer); const phones = customerPhones(customer);
hint.textContent = phones.length const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || '';
? `Клиент ${customer.customer_id} • основной номер ${phones[0]}` hint.textContent = primaryPhone
? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}`
: `Клиент ${customer.customer_id} • омниканальный профиль`; : `Клиент ${customer.customer_id} • омниканальный профиль`;
} }
@@ -2037,6 +2187,7 @@ function applyTelegramCollections(threads, options = {}) {
} }
renderTelegramWorkspace(); renderTelegramWorkspace();
renderCustomerSpotlight(); renderCustomerSpotlight();
renderUnifiedInbox();
} }
async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) { async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) {
@@ -2588,6 +2739,9 @@ function renderWhatsappMessagesTimeline() {
} }
function renderWhatsappWorkspace() { function renderWhatsappWorkspace() {
if (!featureEnabled('whatsapp')) {
return;
}
ensureWhatsappSelection(); ensureWhatsappSelection();
renderWhatsappChatList(); renderWhatsappChatList();
renderWhatsappContextPanel(); renderWhatsappContextPanel();
@@ -2634,6 +2788,9 @@ function renderWhatsappWorkspace() {
} }
async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) { async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) {
if (!featureEnabled('whatsapp')) {
return [];
}
if (!threadId) { if (!threadId) {
state.whatsapp.selectedThreadSummary = null; state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace(); renderWhatsappWorkspace();
@@ -2655,6 +2812,9 @@ async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChat
} }
async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) { async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) {
if (!featureEnabled('whatsapp')) {
return null;
}
const { logResult = false } = options; const { logResult = false } = options;
if (!threadId || state.whatsapp.mode !== 'live') { if (!threadId || state.whatsapp.mode !== 'live') {
state.whatsapp.selectedThreadSummary = null; state.whatsapp.selectedThreadSummary = null;
@@ -2692,6 +2852,9 @@ async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatI
} }
async function loadWhatsappThreads(logResult = true, options = {}) { async function loadWhatsappThreads(logResult = true, options = {}) {
if (!featureEnabled('whatsapp')) {
return [];
}
const { const {
preserveSelection = true, preserveSelection = true,
preserveOnError = false, preserveOnError = false,
@@ -2912,10 +3075,11 @@ async function sendWhatsappMessage() {
} }
function startWhatsappPolling() { function startWhatsappPolling() {
if (state.whatsapp.pollTimer) { if (!featureEnabled('whatsapp')) {
window.clearInterval(state.whatsapp.pollTimer); stopWhatsappPolling();
state.whatsapp.pollTimer = null; return;
} }
stopWhatsappPolling();
state.whatsapp.pollTimer = window.setInterval(() => { state.whatsapp.pollTimer = window.setInterval(() => {
loadWhatsappThreads(false, { loadWhatsappThreads(false, {
preserveSelection: true, preserveSelection: true,
@@ -2926,6 +3090,9 @@ function startWhatsappPolling() {
} }
function handleWhatsappUiAction(event) { function handleWhatsappUiAction(event) {
if (!featureEnabled('whatsapp')) {
return;
}
const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно'; const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно';
if (action === 'chats') { if (action === 'chats') {
return; return;
@@ -3034,8 +3201,8 @@ function operatorHashState(hash = window.location.hash) {
if (raw === 'whatsapp-page' || raw === 'whatsapp') { if (raw === 'whatsapp-page' || raw === 'whatsapp') {
return { view: 'whatsapp', anchor: '', customerId: '' }; return { view: 'whatsapp', anchor: '', customerId: '' };
} }
if (raw === 'voice-debug' || raw === 'voice-debug-page') { if (raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page') {
return { view: 'voice-debug', anchor: '', customerId: '' }; return { view: 'calls', anchor: '', customerId: '' };
} }
if (raw === 'interactions' || raw === 'integrations') { if (raw === 'interactions' || raw === 'integrations') {
return { view: 'workspace', anchor: raw, customerId: '' }; return { view: 'workspace', anchor: raw, customerId: '' };
@@ -3058,12 +3225,12 @@ function applyOperatorViewFromHash(options = {}) {
const { scroll = false } = options; const { scroll = false } = options;
let { view, anchor, customerId } = operatorHashState(); let { view, anchor, customerId } = operatorHashState();
const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`); const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`);
if (viewLink && viewLink.style.display === 'none') { if (!isOperatorViewEnabled(view) || (viewLink && (viewLink.hidden || viewLink.style.display === 'none'))) {
view = 'workspace'; view = 'workspace';
anchor = ''; anchor = '';
if (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page') { window.history.replaceState(null, '', '#workspace');
window.history.replaceState(null, '', '#workspace'); } else if (view === 'calls' && (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page')) {
} window.history.replaceState(null, '', '#calls');
} }
if (customerId) { if (customerId) {
state.customers.selectedCustomerId = customerId; state.customers.selectedCustomerId = customerId;
@@ -3198,6 +3365,17 @@ async function checkGateway() {
} }
} }
async function loadOperatorConfig() {
try {
const response = await fetch('/operator/config');
const data = await response.json();
state.features.whatsapp = Boolean(data?.features?.whatsapp);
} catch {
state.features.whatsapp = false;
}
syncWhatsappFeatureVisibility();
}
async function loadOidcConfig() { async function loadOidcConfig() {
try { try {
const response = await fetch('/proxy/auth/auth/oidc/config'); const response = await fetch('/proxy/auth/auth/oidc/config');
@@ -3304,6 +3482,7 @@ async function searchCustomers() {
state.customers.page = 1; state.customers.page = 1;
state.customers.selectedCustomerId = ''; state.customers.selectedCustomerId = '';
renderCustomerList(); renderCustomerList();
renderUnifiedInbox();
return; return;
} }
if (!$('interactionCustomerId').value.trim()) { if (!$('interactionCustomerId').value.trim()) {
@@ -3314,11 +3493,13 @@ async function searchCustomers() {
const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId); const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId);
state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id; state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id;
renderCustomerList(); renderCustomerList();
renderUnifiedInbox();
} catch (err) { } catch (err) {
state.customers.items = []; state.customers.items = [];
state.customers.page = 1; state.customers.page = 1;
state.customers.selectedCustomerId = ''; state.customers.selectedCustomerId = '';
renderCustomerList(); renderCustomerList();
renderUnifiedInbox();
log('Не удалось загрузить клиентов', { error: err.message }); log('Не удалось загрузить клиентов', { error: err.message });
} }
} }
@@ -3413,6 +3594,321 @@ function renderInteractionBoard(items) {
`; `;
} }
function unifiedInboxSortValue(...values) {
for (const value of values) {
const parsed = new Date(value || '').getTime();
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 0;
}
function unifiedInboxInteractionBucket(item) {
if (!item || item.status === 'closed') {
return '';
}
if (item.status === 'escalated') {
return 'escalated';
}
if (String(item.assigned_to || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
if (item.status === 'new' || !String(item.assigned_to || '').trim()) {
return 'new';
}
return '';
}
function unifiedInboxTelegramBucket(thread) {
if (!thread || thread.status === 'closed') {
return '';
}
if (['handoff_required', 'human_owned'].includes(String(thread.ai_state || '').trim())) {
return 'ai';
}
if (String(thread.claimed_by_user || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
return 'new';
}
function renderUnifiedInboxBadge(label, className = '') {
if (!label) {
return '';
}
return `<span class="micro-badge ${className}">${escapeHtml(label)}</span>`;
}
function unifiedInboxActionLabel(item) {
if (item.kind === 'telegram') {
return 'Открыть чат';
}
if (item.kind === 'call') {
return 'Открыть звонок';
}
return 'К обращению';
}
function buildUnifiedInboxInteractionItem(item) {
const bucket = unifiedInboxInteractionBucket(item);
if (!bucket) {
return null;
}
const badges = [
renderUnifiedInboxBadge(channelLabel(item.channel), 'channel'),
item.queue_id ? renderUnifiedInboxBadge(item.queue_id, 'queue') : '',
item.assigned_to ? renderUnifiedInboxBadge(item.assigned_to, 'owner') : '',
].filter(Boolean);
return {
key: `interaction:${item.interaction_id}`,
kind: 'interaction',
bucket,
sortValue: unifiedInboxSortValue(item.updated_at, item.created_at),
title: item.subject || 'Обращение без темы',
subtitle: item.interaction_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(item.customer_id || '')}`,
`Статус: ${statusMeta(item.status).label}`,
`Обновлено: ${formatIsoShort(item.updated_at || item.created_at)}`,
],
customerId: item.customer_id || '',
interactionId: item.interaction_id,
};
}
function buildUnifiedInboxTelegramItem(thread) {
const bucket = unifiedInboxTelegramBucket(thread);
if (!bucket) {
return null;
}
const customerId = customerIdForInteractionId(thread.interaction_id);
const aiMeta = telegramThreadAiStateMeta(thread);
const unreadCount = telegramThreadUnreadCount(thread);
const badges = [
renderUnifiedInboxBadge('Telegram', 'channel'),
unreadCount > 0 ? renderUnifiedInboxBadge(`${unreadCount} new`, 'queue') : '',
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.tone) : '',
thread.claimed_by_user ? renderUnifiedInboxBadge(thread.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `telegram:${thread.thread_id}`,
kind: 'telegram',
bucket,
sortValue: unifiedInboxSortValue(thread.last_message_at, thread.updated_at, thread.created_at),
title: telegramThreadDisplayName(thread),
subtitle: telegramThreadSummary(thread),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
thread.last_message_preview ? `Последнее: ${thread.last_message_preview}` : 'Последнее сообщение пока не загружено',
`Статус: ${telegramThreadHeaderPresence(thread)}`,
],
customerId,
interactionId: thread.interaction_id || '',
threadId: thread.thread_id,
};
}
function buildUnifiedInboxCallItem(item) {
const aiMeta = voiceAiStatusMeta(item);
const interaction = interactionById(item.interaction_id || '');
const customerId = interaction?.customer_id || '';
const caller = item.caller_name || item.caller_number || item.call_id || 'Неизвестный абонент';
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '',
item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `call:${item.call_id}`,
kind: 'call',
bucket: 'calls',
sortValue: unifiedInboxSortValue(item.started_at, item.updated_at),
title: caller,
subtitle: item.call_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
],
customerId,
interactionId: item.interaction_id || '',
callId: item.call_id,
};
}
function buildUnifiedInboxCollections() {
const collections = {
new: [],
mine: [],
ai: [],
escalated: [],
calls: [],
};
const representedInteractionIds = new Set();
state.telegram.threads.forEach((thread) => {
if (thread?.interaction_id && thread.status !== 'closed') {
representedInteractionIds.add(thread.interaction_id);
}
const item = buildUnifiedInboxTelegramItem(thread);
if (item) {
collections[item.bucket].push(item);
}
});
state.liveCalls.items.forEach((call) => {
if (call?.interaction_id) {
representedInteractionIds.add(call.interaction_id);
}
const item = buildUnifiedInboxCallItem(call);
if (item) {
collections.calls.push(item);
}
});
state.interactions.forEach((item) => {
if (representedInteractionIds.has(item.interaction_id)) {
return;
}
const inboxItem = buildUnifiedInboxInteractionItem(item);
if (inboxItem) {
collections[inboxItem.bucket].push(inboxItem);
}
});
Object.values(collections).forEach((items) => {
items.sort((left, right) => right.sortValue - left.sortValue);
});
return collections;
}
function renderUnifiedInboxCard(item) {
const actions = [
`<button type="button" data-inbox-action="open" data-inbox-kind="${escapeHtml(item.kind)}" data-thread-id="${escapeHtml(item.threadId || '')}" data-interaction-id="${escapeHtml(item.interactionId || '')}" data-call-id="${escapeHtml(item.callId || '')}" data-customer-id="${escapeHtml(item.customerId || '')}">${escapeHtml(unifiedInboxActionLabel(item))}</button>`,
item.customerId ? `<button type="button" data-inbox-action="customer" data-customer-id="${escapeHtml(item.customerId)}">К клиенту</button>` : '',
].filter(Boolean).join('');
return `
<article class="pipeline-card unified-inbox-card${item.bucket === 'escalated' ? ' escalated' : ''}">
<div class="card-badges">${item.badges.join('')}</div>
<div class="row-item-head">
<h3 class="card-title">${escapeHtml(item.title)}</h3>
</div>
<p class="card-subtitle">${escapeHtml(item.subtitle)}</p>
${item.metaLines.map((line) => `<p class="card-meta-line">${escapeHtml(line)}</p>`).join('')}
<div class="card-divider"></div>
<div class="row-actions">
${actions}
</div>
</article>
`;
}
function renderUnifiedInbox() {
const board = $('unifiedInboxBoard');
const summary = $('unifiedInboxSummary');
if (!board || !summary) {
return;
}
const collections = buildUnifiedInboxCollections();
const total = Object.values(collections).reduce((count, items) => count + items.length, 0);
summary.innerHTML = [
renderSummaryCard('В очереди', String(total), 'Все задачи без дублей между каналами'),
renderSummaryCard('Новые', String(collections.new.length), 'Свободные обращения и чаты'),
renderSummaryCard('Мои', String(collections.mine.length), 'Закреплено за текущим пользователем'),
renderSummaryCard('AI handoff', String(collections.ai.length), 'Диалоги, где AI позвал человека'),
renderSummaryCard('Звонки', String(collections.calls.length), 'Активные голосовые разговоры'),
renderSummaryCard('Эскалации', String(collections.escalated.length), 'Очередь второй линии и спорные кейсы'),
].join('');
board.innerHTML = `
<div class="pipeline-board unified-inbox-board">
${UNIFIED_INBOX_COLUMNS.map((column) => `
<section class="pipeline-column">
<div class="pipeline-head">
<div class="pipeline-title">${escapeHtml(column.title)}</div>
<div class="pipeline-count">${collections[column.key].length}</div>
</div>
<div class="pipeline-stack">
${collections[column.key].length
? collections[column.key].map((item) => renderUnifiedInboxCard(item)).join('')
: `<div class="empty-state">${escapeHtml(column.emptyMessage)}</div>`}
</div>
</section>
`).join('')}
</div>
`;
}
function focusLiveCall(callId) {
if (!callId) {
return;
}
const select = $('liveCallIdSelect');
if (select) {
select.value = callId;
}
state.liveCalls.selectedCallId = callId;
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncLiveCallActionButtons();
const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null;
ensureVoiceAiSummaryLoaded(activeCall);
}
async function openUnifiedInboxItem(button) {
const kind = button.dataset.inboxKind || '';
const interactionId = button.dataset.interactionId || '';
const threadId = button.dataset.threadId || '';
const callId = button.dataset.callId || '';
const customerId = button.dataset.customerId || '';
if (kind === 'telegram') {
window.location.hash = '#telegram-page';
await selectTelegramThread(threadId);
return;
}
if (kind === 'call') {
focusLiveCall(callId);
window.location.hash = '#calls';
return;
}
if (customerId) {
$('interactionCustomerId').value = customerId;
}
window.location.hash = '#interactions';
log('Открыто обращение из единой очереди', { interaction_id: interactionId, customer_id: customerId || '-' });
}
function handleUnifiedInboxClick(event) {
const button = event.target.closest('[data-inbox-action]');
if (!button) {
return;
}
if (button.dataset.inboxAction === 'customer') {
openCustomerProfile(button.dataset.customerId || '');
return;
}
openUnifiedInboxItem(button).catch((err) => {
log('Не удалось открыть элемент единой очереди', { error: err.message });
});
}
async function refreshUnifiedInbox() {
await Promise.all([
searchCustomers(),
loadInteractions(),
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }),
loadLiveCalls(false, { preserveStateOnError: true }),
selectedCustomer() ? ensureCustomerHistoryLoaded(selectedCustomer().customer_id, { force: true }) : Promise.resolve(null),
]);
log('Единая очередь обновлена', { total: Object.values(buildUnifiedInboxCollections()).reduce((count, items) => count + items.length, 0) });
}
function telephonyLabel(value) { function telephonyLabel(value) {
return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно'; return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно';
} }
@@ -4959,6 +5455,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) {
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncBrowserPhonePopupLifecycle(); syncBrowserPhonePopupLifecycle();
renderCustomerSpotlight(); renderCustomerSpotlight();
renderUnifiedInbox();
} }
function applyClaimedLiveCall(item) { function applyClaimedLiveCall(item) {
@@ -5059,14 +5556,17 @@ async function loadInteractions() {
if (!data.length) { if (!data.length) {
setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.'); setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.');
renderCustomerSpotlight(); renderCustomerSpotlight();
renderUnifiedInbox();
return; return;
} }
$('interactionTable').innerHTML = renderInteractionBoard(data); $('interactionTable').innerHTML = renderInteractionBoard(data);
renderCustomerSpotlight(); renderCustomerSpotlight();
renderUnifiedInbox();
} catch (err) { } catch (err) {
state.interactions = []; state.interactions = [];
setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.'); setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.');
renderCustomerSpotlight(); renderCustomerSpotlight();
renderUnifiedInbox();
log('Не удалось загрузить обращения', { error: err.message }); log('Не удалось загрузить обращения', { error: err.message });
} }
} }
@@ -5171,13 +5671,18 @@ function wire() {
$('loginBtn').addEventListener('click', login); $('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin); $('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => { $('refreshBtn').addEventListener('click', async () => {
await searchCustomers(); await refreshUnifiedInbox();
await loadInteractions(); if (featureEnabled('whatsapp')) {
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }); await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true }); }
await loadLiveCalls(false);
log('Данные на экране обновлены'); log('Данные на экране обновлены');
}); });
$('refreshUnifiedInboxBtn').addEventListener('click', () => {
refreshUnifiedInbox().catch((err) => {
log('Не удалось обновить единую очередь', { error: err.message });
});
});
$('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick);
$('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm()); $('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm());
$('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false)); $('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false));
$('createCustomerBtn').addEventListener('click', createCustomer); $('createCustomerBtn').addEventListener('click', createCustomer);
@@ -5289,54 +5794,56 @@ function wire() {
event.preventDefault(); event.preventDefault();
sendTelegramReply(); sendTelegramReply();
}); });
$('whatsappSearchInput').addEventListener('input', (event) => { if (featureEnabled('whatsapp')) {
state.whatsapp.searchQuery = event.target.value || ''; $('whatsappSearchInput').addEventListener('input', (event) => {
renderWhatsappWorkspace(); state.whatsapp.searchQuery = event.target.value || '';
}); renderWhatsappWorkspace();
$('whatsappFilterBar').addEventListener('click', (event) => { });
const button = event.target.closest('[data-whatsapp-filter]'); $('whatsappFilterBar').addEventListener('click', (event) => {
if (!button) { const button = event.target.closest('[data-whatsapp-filter]');
return; if (!button) {
} return;
setWhatsappFilter(button.dataset.whatsappFilter || 'all'); }
}); setWhatsappFilter(button.dataset.whatsappFilter || 'all');
$('whatsappClaimBtn').addEventListener('click', () => { });
claimWhatsappThread().catch(() => {}); $('whatsappClaimBtn').addEventListener('click', () => {
}); claimWhatsappThread().catch(() => {});
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi); });
$('whatsappChatList').addEventListener('click', (event) => { $('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
const button = event.target.closest('[data-whatsapp-chat-id]'); $('whatsappChatList').addEventListener('click', (event) => {
if (!button) { const button = event.target.closest('[data-whatsapp-chat-id]');
return; if (!button) {
} return;
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); }
}); selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
$('whatsappChatList').addEventListener('keydown', (event) => { });
if (event.key !== 'Enter' && event.key !== ' ') { $('whatsappChatList').addEventListener('keydown', (event) => {
return; if (event.key !== 'Enter' && event.key !== ' ') {
} return;
const button = event.target.closest('[data-whatsapp-chat-id]'); }
if (!button) { const button = event.target.closest('[data-whatsapp-chat-id]');
return; if (!button) {
} return;
event.preventDefault(); }
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); event.preventDefault();
}); selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => { });
button.addEventListener('click', handleWhatsappUiAction); document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
}); button.addEventListener('click', handleWhatsappUiAction);
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage); });
$('whatsappComposerInput').addEventListener('input', (event) => { $('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
state.whatsapp.composerText = event.target.value || ''; $('whatsappComposerInput').addEventListener('input', (event) => {
syncWhatsappComposerUi(); state.whatsapp.composerText = event.target.value || '';
}); syncWhatsappComposerUi();
$('whatsappComposerInput').addEventListener('keydown', (event) => { });
if (event.key !== 'Enter' || event.shiftKey) { $('whatsappComposerInput').addEventListener('keydown', (event) => {
return; if (event.key !== 'Enter' || event.shiftKey) {
} return;
event.preventDefault(); }
sendWhatsappMessage(); event.preventDefault();
}); sendWhatsappMessage();
});
}
$('createInteractionBtn').addEventListener('click', createInteraction); $('createInteractionBtn').addEventListener('click', createInteraction);
$('loadInteractionsBtn').addEventListener('click', loadInteractions); $('loadInteractionsBtn').addEventListener('click', loadInteractions);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true)); $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true));
@@ -5383,6 +5890,7 @@ async function init() {
window.location.href = '/'; window.location.href = '/';
return; return;
} }
await loadOperatorConfig();
wire(); wire();
applyOperatorViewFromHash(); applyOperatorViewFromHash();
$('defaultAssignee').value = DEMO_ASSIGNEE; $('defaultAssignee').value = DEMO_ASSIGNEE;
@@ -5390,7 +5898,10 @@ async function init() {
$('telegramEscalationQueue').value = DEMO_QUEUE; $('telegramEscalationQueue').value = DEMO_QUEUE;
updateSessionInfo(); updateSessionInfo();
syncLiveCallActionButtons(); syncLiveCallActionButtons();
renderWhatsappWorkspace(); renderUnifiedInbox();
if (featureEnabled('whatsapp')) {
renderWhatsappWorkspace();
}
renderTelegramWorkspace(); renderTelegramWorkspace();
await Promise.all([checkGateway(), loadOidcConfig()]); await Promise.all([checkGateway(), loadOidcConfig()]);
await fetchBrowserSoftphoneConfig(); await fetchBrowserSoftphoneConfig();
@@ -5399,11 +5910,15 @@ async function init() {
await searchCustomers(); await searchCustomers();
await loadInteractions(); await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }); await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true }); if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
await loadLiveCalls(false); await loadLiveCalls(false);
startLiveCallsPolling(); startLiveCallsPolling();
startTelegramPolling(); startTelegramPolling();
startWhatsappPolling(); if (featureEnabled('whatsapp')) {
startWhatsappPolling();
}
log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE }); log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE });
} }
+24 -12
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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=track17-whatsapp-live1" /> <link rel="stylesheet" href="/operator/assets/styles.css?v=track19-unified-inbox1" />
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@@ -33,8 +33,8 @@
<a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a> <a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a>
<a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a> <a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a>
<a class="nav-link" data-operator-view-link="telegram" href="#telegram-page"><span class="nav-bullet"></span>Telegram</a> <a class="nav-link" data-operator-view-link="telegram" href="#telegram-page"><span class="nav-bullet"></span>Telegram</a>
<a class="nav-link" data-operator-view-link="whatsapp" href="#whatsapp-page"><span class="nav-bullet"></span>WhatsApp</a> <a class="nav-link" data-operator-view-link="whatsapp" data-feature="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
<a class="nav-link" data-operator-view-link="voice-debug" data-roles="admin,operator" href="#voice-debug"><span class="nav-bullet"></span>Голосовая диагностика</a> <a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
</div> </div>
</nav> </nav>
@@ -137,13 +137,25 @@
</section> </section>
<section class="grid"> <section class="grid">
<article class="panel reveal panel-span-full unified-inbox-panel" id="unifiedInbox">
<div class="unified-inbox-head">
<div>
<h2>Единая очередь</h2>
<p class="hint">Новые задачи, мои диалоги, AI handoff, эскалации и живые звонки в одном месте.</p>
</div>
<button id="refreshUnifiedInboxBtn" class="btn ghost" type="button">Обновить очередь</button>
</div>
<div id="unifiedInboxSummary" class="summary-grid"></div>
<div id="unifiedInboxBoard" class="unified-inbox-scroll"></div>
</article>
<article class="panel reveal panel-span-full" id="interactions"> <article class="panel reveal panel-span-full" id="interactions">
<h2>Обращения</h2> <h2>Обращения</h2>
<div class="inline-form"> <div class="inline-form">
<select id="interactionChannel"> <select id="interactionChannel">
<option value="voice">Голос</option> <option value="voice">Голос</option>
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp</option> <option value="whatsapp" data-feature-option="whatsapp" hidden disabled>WhatsApp</option>
<option value="webchat">Веб-чат</option> <option value="webchat">Веб-чат</option>
<option value="email">Электронная почта</option> <option value="email">Электронная почта</option>
</select> </select>
@@ -362,7 +374,7 @@
</article> </article>
</section> </section>
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp"> <section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp" data-feature="whatsapp" hidden>
<article class="panel reveal panel-span-full whatsapp-panel" id="whatsappWorkspace"> <article class="panel reveal panel-span-full whatsapp-panel" id="whatsappWorkspace">
<div class="whatsapp-shell"> <div class="whatsapp-shell">
<aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation"> <aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation">
@@ -535,15 +547,15 @@
</article> </article>
</section> </section>
<section id="voiceDebugView" class="operator-view hidden" data-operator-view="voice-debug"> <section id="callsView" class="operator-view hidden" data-operator-view="calls">
<section class="page-hero reveal customers-page-hero"> <section class="page-hero reveal customers-page-hero">
<h1>Голосовая диагностика</h1> <h1>Звонки</h1>
<p class="hint">Технический экран живых и недавних звонков с трассировкой действий. Основной операторский сценарий остаётся во всплывающем окне звонка.</p> <p class="hint">Живые и недавние звонки, AI-handoff и журнал действий в одном месте. Основной take over остаётся во всплывающем окне браузерного телефона.</p>
</section> </section>
<article class="panel reveal panel-span-full technical-panel" id="liveCalls"> <article class="panel reveal panel-span-full technical-panel" id="liveCalls">
<h2>Голосовой мониторинг</h2> <h2>Мониторинг звонков</h2>
<p class="hint">Этот экран нужен только для диагностики. Для обработки звонка оператор использует всплывающее окно звонка в браузере.</p> <p class="hint">Здесь видны текущий статус, недавние вызовы и трассировка действий. Ответ, перевод и завершение звонка остаются во всплывающем окне.</p>
<div class="inline-form compact"> <div class="inline-form compact">
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button> <button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
<button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button> <button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button>
@@ -551,7 +563,7 @@
<div class="inline-form compact live-call-inspector"> <div class="inline-form compact live-call-inspector">
<select id="liveCallIdSelect"></select> <select id="liveCallIdSelect"></select>
</div> </div>
<p class="hint">Здесь остаётся технический слой живых и недавних звонков, а управление перенесено во всплывающее окно.</p> <p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p> <p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
<div id="liveCallsTable" class="table"></div> <div id="liveCallsTable" class="table"></div>
<details class="details-block"> <details class="details-block">
@@ -603,6 +615,6 @@
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script> <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/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
<script src="/operator/assets/app.js?v=track36-operator-ru-polish"></script> <script src="/operator/assets/app.js?v=track38-unified-inbox1"></script>
</body> </body>
</html> </html>
+32 -1
View File
@@ -2968,6 +2968,32 @@ textarea::placeholder {
align-items: start; align-items: start;
} }
.unified-inbox-panel {
display: grid;
gap: 16px;
}
.unified-inbox-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.unified-inbox-scroll {
overflow-x: auto;
padding-bottom: 4px;
}
.unified-inbox-board {
grid-template-columns: repeat(5, minmax(240px, 1fr));
min-width: 1320px;
}
.unified-inbox-card {
min-height: 248px;
}
.live-calls-board { .live-calls-board {
grid-template-columns: repeat(2, minmax(280px, 1fr)); grid-template-columns: repeat(2, minmax(280px, 1fr));
} }
@@ -4967,7 +4993,8 @@ body.analytics-drilldown-open {
.analytics-compare-head, .analytics-compare-head,
.analytics-subhead, .analytics-subhead,
.analytics-chart-meta, .analytics-chart-meta,
.analytics-narrative-meta { .analytics-narrative-meta,
.unified-inbox-head {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
@@ -4985,6 +5012,10 @@ body.analytics-drilldown-open {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.unified-inbox-board {
min-width: 0;
}
.customer-spotlight-grid, .customer-spotlight-grid,
.customer-spotlight-grid.page, .customer-spotlight-grid.page,
.customer-profile-summary-grid, .customer-profile-summary-grid,