Improve operator CRM flows and UI
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user