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)
|
||||
def health() -> HealthResponse:
|
||||
return HealthResponse(status="ok", service="api-gateway")
|
||||
@@ -179,6 +186,15 @@ def registry() -> dict:
|
||||
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")
|
||||
def contracts() -> dict:
|
||||
return {
|
||||
|
||||
@@ -1,24 +1,136 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from services.shared.core import new_id, utc_now_iso
|
||||
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_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")
|
||||
|
||||
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:
|
||||
phones = json.loads(row.phones_json or "[]")
|
||||
tags = json.loads(row.tags_json or "[]")
|
||||
phones = _json_list(row.phones_json)
|
||||
tags = _json_list(row.tags_json)
|
||||
return CustomerOut(
|
||||
customer_id=row.customer_id,
|
||||
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)
|
||||
def health() -> HealthResponse:
|
||||
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()
|
||||
|
||||
|
||||
@app.get("/customers/{customer_id}/history")
|
||||
def customer_history(customer_id: str) -> dict:
|
||||
@app.get("/customers/{customer_id}/history", response_model=CustomerHistoryOut)
|
||||
def customer_history(customer_id: str) -> CustomerHistoryOut:
|
||||
session = get_session()
|
||||
try:
|
||||
row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none()
|
||||
if not row:
|
||||
customer = session.execute(
|
||||
select(Customer).where(Customer.customer_id == customer_id)
|
||||
).scalar_one_or_none()
|
||||
if not customer:
|
||||
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:
|
||||
session.close()
|
||||
|
||||
@@ -96,6 +96,36 @@ class CustomerOut(CustomerCreate):
|
||||
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):
|
||||
channel: Channel
|
||||
subject: str = Field(min_length=3)
|
||||
|
||||
@@ -14,6 +14,13 @@ def test_operator_ui_route_exists():
|
||||
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():
|
||||
client = TestClient(app)
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
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")
|
||||
@@ -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")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'id="liveCalls"' in index_html
|
||||
assert 'data-operator-view="voice-debug"' in index_html
|
||||
assert 'data-operator-view-link="voice-debug"' in index_html
|
||||
assert 'href="#voice-debug"' in index_html
|
||||
assert 'id="callsView"' in index_html
|
||||
assert 'data-operator-view="calls"' 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="loadLiveCallActionsBtn"' 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 "function terminalActionLabel" 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="interactions"' 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
|
||||
|
||||
|
||||
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():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
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.routing_service.app import app as routing_app
|
||||
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():
|
||||
@@ -62,6 +69,191 @@ def test_customer_and_interaction_lifecycle():
|
||||
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():
|
||||
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())
|
||||
assert thread_list.status_code == 200
|
||||
assert thread_list.json()[0]["chat_id"] == "chat_1"
|
||||
assert thread_list.json()[0]["interaction_id"] == payload["interaction_id"]
|
||||
listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_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']}")
|
||||
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())
|
||||
assert thread_list.status_code == 200
|
||||
assert thread_list.json()[0]["chat_id"] == "wa_chat_1"
|
||||
assert thread_list.json()[0]["phone_number"] == "+77015550001"
|
||||
assert thread_list.json()[0]["unread_count"] == 1
|
||||
listed_thread = next(item for item in thread_list.json() if item["thread_id"] == payload["thread_id"])
|
||||
assert listed_thread["chat_id"] == "wa_chat_1"
|
||||
assert listed_thread["phone_number"] == "+77015550001"
|
||||
assert listed_thread["unread_count"] == 1
|
||||
|
||||
interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}")
|
||||
assert interaction.status_code == 200
|
||||
|
||||
+601
-86
@@ -4,6 +4,9 @@
|
||||
token: null,
|
||||
authSource: 'local',
|
||||
fullName: null,
|
||||
features: {
|
||||
whatsapp: false,
|
||||
},
|
||||
logLines: [],
|
||||
interactions: [],
|
||||
oidc: {
|
||||
@@ -26,6 +29,9 @@
|
||||
pageSize: 7,
|
||||
selectedCustomerId: '',
|
||||
leadFormOpen: false,
|
||||
historyById: {},
|
||||
pendingHistoryById: {},
|
||||
historyErrorsById: {},
|
||||
},
|
||||
telegram: {
|
||||
threads: [],
|
||||
@@ -346,6 +352,14 @@ const BOARD_COLUMNS = [
|
||||
{ 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 = {
|
||||
ringing: 'Звонит',
|
||||
claimed: 'Взято',
|
||||
@@ -373,9 +387,62 @@ const OPERATOR_VIEW_IDS = {
|
||||
'customer-profile': 'customerProfileView',
|
||||
telegram: 'telegramView',
|
||||
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() {
|
||||
state.user = $('sessionUser').value.trim() || 'admin';
|
||||
state.role = $('sessionRole').value || 'admin';
|
||||
@@ -469,6 +536,7 @@ function logout() {
|
||||
window.clearInterval(state.telegram.pollTimer);
|
||||
state.telegram.pollTimer = null;
|
||||
}
|
||||
stopWhatsappPolling();
|
||||
clearStoredSession();
|
||||
window.location.href = '/';
|
||||
}
|
||||
@@ -612,9 +680,26 @@ function customerProfileHash(customerId = state.customers.selectedCustomerId) {
|
||||
|
||||
function openCustomerProfile(customerId) {
|
||||
selectCustomer(customerId || '');
|
||||
ensureCustomerHistoryLoaded(customerId || state.customers.selectedCustomerId, { force: true }).catch(() => {});
|
||||
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) {
|
||||
if (!customer) {
|
||||
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) {
|
||||
const counters = new Map();
|
||||
customerInteractions(customer).forEach((item) => {
|
||||
@@ -835,23 +957,48 @@ function customerSpotlightMarkup(customer, options = {}) {
|
||||
const source = customerLeadSourceMeta(customer, index);
|
||||
const leadStatus = customerLeadStatusMeta(customer, index);
|
||||
const score = customerLeadScore(customer, index);
|
||||
const interactions = customerInteractions(customer);
|
||||
const threads = customerTelegramThreads(customer);
|
||||
const liveCalls = customerLiveCalls(customer);
|
||||
const historyEvents = buildCustomerHistoryEvents(customer);
|
||||
const historyPayload = customerHistoryPayload(customer.customer_id);
|
||||
const historySummary = historyPayload?.summary || null;
|
||||
const interactions = Array.isArray(historyPayload?.interactions)
|
||||
? 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 openCases = interactions.filter((item) => item.status !== 'closed').length;
|
||||
const activeChannels = [...new Set([
|
||||
...interactions.map((item) => item.channel).filter(Boolean),
|
||||
...(threads.length ? ['telegram'] : []),
|
||||
...(liveCalls.length ? ['voice'] : []),
|
||||
])];
|
||||
const openCases = Number.isFinite(Number(historySummary?.open_cases))
|
||||
? Number(historySummary.open_cases)
|
||||
: interactions.filter((item) => item.status !== 'closed').length;
|
||||
const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length
|
||||
? 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 primaryThread = [...threads].sort((left, right) => {
|
||||
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at);
|
||||
})[0] || null;
|
||||
const primaryThread = historySummary?.primary_telegram_thread_id
|
||||
? threads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || 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 = [
|
||||
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(
|
||||
'Каналы',
|
||||
@@ -860,8 +1007,8 @@ function customerSpotlightMarkup(customer, options = {}) {
|
||||
),
|
||||
renderSummaryCard(
|
||||
'Последний контакт',
|
||||
latestEvent ? formatIsoShort(latestEvent.timestamp) : '—',
|
||||
latestEvent ? latestEvent.title : 'Активность ещё не зафиксирована',
|
||||
historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'),
|
||||
historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована',
|
||||
),
|
||||
].join('');
|
||||
|
||||
@@ -908,7 +1055,7 @@ function customerSpotlightMarkup(customer, options = {}) {
|
||||
<div class="customer-profile-meta-grid">
|
||||
<article class="customer-meta-card">
|
||||
<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>
|
||||
</article>
|
||||
<article class="customer-meta-card">
|
||||
@@ -942,7 +1089,7 @@ function customerSpotlightMarkup(customer, options = {}) {
|
||||
<div class="customer-profile-kicker">Единая история клиента</div>
|
||||
<h3>Все контакты в одной ленте</h3>
|
||||
</div>
|
||||
<div class="customer-history-caption">${escapeHtml(latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения')}</div>
|
||||
<div class="customer-history-caption">${escapeHtml(historyStatusText)}</div>
|
||||
</div>
|
||||
<div class="customer-history-list">
|
||||
${historyMarkup}
|
||||
@@ -994,10 +1141,13 @@ function renderCustomerProfilePage() {
|
||||
if (title) {
|
||||
title.textContent = customer.display_name || 'Профиль клиента';
|
||||
}
|
||||
const historyPayload = customerHistoryPayload(customer.customer_id);
|
||||
ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});
|
||||
if (hint) {
|
||||
const phones = customerPhones(customer);
|
||||
hint.textContent = phones.length
|
||||
? `Клиент ${customer.customer_id} • основной номер ${phones[0]}`
|
||||
const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || '';
|
||||
hint.textContent = primaryPhone
|
||||
? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}`
|
||||
: `Клиент ${customer.customer_id} • омниканальный профиль`;
|
||||
}
|
||||
|
||||
@@ -2037,6 +2187,7 @@ function applyTelegramCollections(threads, options = {}) {
|
||||
}
|
||||
renderTelegramWorkspace();
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
}
|
||||
|
||||
async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) {
|
||||
@@ -2588,6 +2739,9 @@ function renderWhatsappMessagesTimeline() {
|
||||
}
|
||||
|
||||
function renderWhatsappWorkspace() {
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
return;
|
||||
}
|
||||
ensureWhatsappSelection();
|
||||
renderWhatsappChatList();
|
||||
renderWhatsappContextPanel();
|
||||
@@ -2634,6 +2788,9 @@ function renderWhatsappWorkspace() {
|
||||
}
|
||||
|
||||
async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) {
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
return [];
|
||||
}
|
||||
if (!threadId) {
|
||||
state.whatsapp.selectedThreadSummary = null;
|
||||
renderWhatsappWorkspace();
|
||||
@@ -2655,6 +2812,9 @@ async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChat
|
||||
}
|
||||
|
||||
async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) {
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
return null;
|
||||
}
|
||||
const { logResult = false } = options;
|
||||
if (!threadId || state.whatsapp.mode !== 'live') {
|
||||
state.whatsapp.selectedThreadSummary = null;
|
||||
@@ -2692,6 +2852,9 @@ async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatI
|
||||
}
|
||||
|
||||
async function loadWhatsappThreads(logResult = true, options = {}) {
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
return [];
|
||||
}
|
||||
const {
|
||||
preserveSelection = true,
|
||||
preserveOnError = false,
|
||||
@@ -2912,10 +3075,11 @@ async function sendWhatsappMessage() {
|
||||
}
|
||||
|
||||
function startWhatsappPolling() {
|
||||
if (state.whatsapp.pollTimer) {
|
||||
window.clearInterval(state.whatsapp.pollTimer);
|
||||
state.whatsapp.pollTimer = null;
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
stopWhatsappPolling();
|
||||
return;
|
||||
}
|
||||
stopWhatsappPolling();
|
||||
state.whatsapp.pollTimer = window.setInterval(() => {
|
||||
loadWhatsappThreads(false, {
|
||||
preserveSelection: true,
|
||||
@@ -2926,6 +3090,9 @@ function startWhatsappPolling() {
|
||||
}
|
||||
|
||||
function handleWhatsappUiAction(event) {
|
||||
if (!featureEnabled('whatsapp')) {
|
||||
return;
|
||||
}
|
||||
const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно';
|
||||
if (action === 'chats') {
|
||||
return;
|
||||
@@ -3034,8 +3201,8 @@ function operatorHashState(hash = window.location.hash) {
|
||||
if (raw === 'whatsapp-page' || raw === 'whatsapp') {
|
||||
return { view: 'whatsapp', anchor: '', customerId: '' };
|
||||
}
|
||||
if (raw === 'voice-debug' || raw === 'voice-debug-page') {
|
||||
return { view: 'voice-debug', anchor: '', customerId: '' };
|
||||
if (raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page') {
|
||||
return { view: 'calls', anchor: '', customerId: '' };
|
||||
}
|
||||
if (raw === 'interactions' || raw === 'integrations') {
|
||||
return { view: 'workspace', anchor: raw, customerId: '' };
|
||||
@@ -3058,12 +3225,12 @@ function applyOperatorViewFromHash(options = {}) {
|
||||
const { scroll = false } = options;
|
||||
let { view, anchor, customerId } = operatorHashState();
|
||||
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';
|
||||
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) {
|
||||
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() {
|
||||
try {
|
||||
const response = await fetch('/proxy/auth/auth/oidc/config');
|
||||
@@ -3304,6 +3482,7 @@ async function searchCustomers() {
|
||||
state.customers.page = 1;
|
||||
state.customers.selectedCustomerId = '';
|
||||
renderCustomerList();
|
||||
renderUnifiedInbox();
|
||||
return;
|
||||
}
|
||||
if (!$('interactionCustomerId').value.trim()) {
|
||||
@@ -3314,11 +3493,13 @@ async function searchCustomers() {
|
||||
const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId);
|
||||
state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id;
|
||||
renderCustomerList();
|
||||
renderUnifiedInbox();
|
||||
} catch (err) {
|
||||
state.customers.items = [];
|
||||
state.customers.page = 1;
|
||||
state.customers.selectedCustomerId = '';
|
||||
renderCustomerList();
|
||||
renderUnifiedInbox();
|
||||
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) {
|
||||
return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно';
|
||||
}
|
||||
@@ -4959,6 +5455,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) {
|
||||
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
|
||||
syncBrowserPhonePopupLifecycle();
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
}
|
||||
|
||||
function applyClaimedLiveCall(item) {
|
||||
@@ -5059,14 +5556,17 @@ async function loadInteractions() {
|
||||
if (!data.length) {
|
||||
setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.');
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
return;
|
||||
}
|
||||
$('interactionTable').innerHTML = renderInteractionBoard(data);
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
} catch (err) {
|
||||
state.interactions = [];
|
||||
setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.');
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
log('Не удалось загрузить обращения', { error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -5171,13 +5671,18 @@ function wire() {
|
||||
$('loginBtn').addEventListener('click', login);
|
||||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
||||
$('refreshBtn').addEventListener('click', async () => {
|
||||
await searchCustomers();
|
||||
await loadInteractions();
|
||||
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
|
||||
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
|
||||
await loadLiveCalls(false);
|
||||
await refreshUnifiedInbox();
|
||||
if (featureEnabled('whatsapp')) {
|
||||
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
|
||||
}
|
||||
log('Данные на экране обновлены');
|
||||
});
|
||||
$('refreshUnifiedInboxBtn').addEventListener('click', () => {
|
||||
refreshUnifiedInbox().catch((err) => {
|
||||
log('Не удалось обновить единую очередь', { error: err.message });
|
||||
});
|
||||
});
|
||||
$('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick);
|
||||
$('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm());
|
||||
$('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false));
|
||||
$('createCustomerBtn').addEventListener('click', createCustomer);
|
||||
@@ -5289,54 +5794,56 @@ function wire() {
|
||||
event.preventDefault();
|
||||
sendTelegramReply();
|
||||
});
|
||||
$('whatsappSearchInput').addEventListener('input', (event) => {
|
||||
state.whatsapp.searchQuery = event.target.value || '';
|
||||
renderWhatsappWorkspace();
|
||||
});
|
||||
$('whatsappFilterBar').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-whatsapp-filter]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
setWhatsappFilter(button.dataset.whatsappFilter || 'all');
|
||||
});
|
||||
$('whatsappClaimBtn').addEventListener('click', () => {
|
||||
claimWhatsappThread().catch(() => {});
|
||||
});
|
||||
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
|
||||
$('whatsappChatList').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-whatsapp-chat-id]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
|
||||
});
|
||||
$('whatsappChatList').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('[data-whatsapp-chat-id]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
|
||||
});
|
||||
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
|
||||
button.addEventListener('click', handleWhatsappUiAction);
|
||||
});
|
||||
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
|
||||
$('whatsappComposerInput').addEventListener('input', (event) => {
|
||||
state.whatsapp.composerText = event.target.value || '';
|
||||
syncWhatsappComposerUi();
|
||||
});
|
||||
$('whatsappComposerInput').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
sendWhatsappMessage();
|
||||
});
|
||||
if (featureEnabled('whatsapp')) {
|
||||
$('whatsappSearchInput').addEventListener('input', (event) => {
|
||||
state.whatsapp.searchQuery = event.target.value || '';
|
||||
renderWhatsappWorkspace();
|
||||
});
|
||||
$('whatsappFilterBar').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-whatsapp-filter]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
setWhatsappFilter(button.dataset.whatsappFilter || 'all');
|
||||
});
|
||||
$('whatsappClaimBtn').addEventListener('click', () => {
|
||||
claimWhatsappThread().catch(() => {});
|
||||
});
|
||||
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
|
||||
$('whatsappChatList').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-whatsapp-chat-id]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
|
||||
});
|
||||
$('whatsappChatList').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('[data-whatsapp-chat-id]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
|
||||
});
|
||||
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
|
||||
button.addEventListener('click', handleWhatsappUiAction);
|
||||
});
|
||||
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
|
||||
$('whatsappComposerInput').addEventListener('input', (event) => {
|
||||
state.whatsapp.composerText = event.target.value || '';
|
||||
syncWhatsappComposerUi();
|
||||
});
|
||||
$('whatsappComposerInput').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
sendWhatsappMessage();
|
||||
});
|
||||
}
|
||||
$('createInteractionBtn').addEventListener('click', createInteraction);
|
||||
$('loadInteractionsBtn').addEventListener('click', loadInteractions);
|
||||
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true));
|
||||
@@ -5383,6 +5890,7 @@ async function init() {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
await loadOperatorConfig();
|
||||
wire();
|
||||
applyOperatorViewFromHash();
|
||||
$('defaultAssignee').value = DEMO_ASSIGNEE;
|
||||
@@ -5390,7 +5898,10 @@ async function init() {
|
||||
$('telegramEscalationQueue').value = DEMO_QUEUE;
|
||||
updateSessionInfo();
|
||||
syncLiveCallActionButtons();
|
||||
renderWhatsappWorkspace();
|
||||
renderUnifiedInbox();
|
||||
if (featureEnabled('whatsapp')) {
|
||||
renderWhatsappWorkspace();
|
||||
}
|
||||
renderTelegramWorkspace();
|
||||
await Promise.all([checkGateway(), loadOidcConfig()]);
|
||||
await fetchBrowserSoftphoneConfig();
|
||||
@@ -5399,11 +5910,15 @@ async function init() {
|
||||
await searchCustomers();
|
||||
await loadInteractions();
|
||||
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);
|
||||
startLiveCallsPolling();
|
||||
startTelegramPolling();
|
||||
startWhatsappPolling();
|
||||
if (featureEnabled('whatsapp')) {
|
||||
startWhatsappPolling();
|
||||
}
|
||||
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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track17-whatsapp-live1" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track19-unified-inbox1" />
|
||||
</head>
|
||||
<body>
|
||||
<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" 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="whatsapp" href="#whatsapp-page"><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="whatsapp" data-feature="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
|
||||
<a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -137,13 +137,25 @@
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<h2>Обращения</h2>
|
||||
<div class="inline-form">
|
||||
<select id="interactionChannel">
|
||||
<option value="voice">Голос</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="email">Электронная почта</option>
|
||||
</select>
|
||||
@@ -362,7 +374,7 @@
|
||||
</article>
|
||||
</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">
|
||||
<div class="whatsapp-shell">
|
||||
<aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation">
|
||||
@@ -535,15 +547,15 @@
|
||||
</article>
|
||||
</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">
|
||||
<h1>Голосовая диагностика</h1>
|
||||
<p class="hint">Технический экран живых и недавних звонков с трассировкой действий. Основной операторский сценарий остаётся во всплывающем окне звонка.</p>
|
||||
<h1>Звонки</h1>
|
||||
<p class="hint">Живые и недавние звонки, AI-handoff и журнал действий в одном месте. Основной take over остаётся во всплывающем окне браузерного телефона.</p>
|
||||
</section>
|
||||
|
||||
<article class="panel reveal panel-span-full technical-panel" id="liveCalls">
|
||||
<h2>Голосовой мониторинг</h2>
|
||||
<p class="hint">Этот экран нужен только для диагностики. Для обработки звонка оператор использует всплывающее окно звонка в браузере.</p>
|
||||
<h2>Мониторинг звонков</h2>
|
||||
<p class="hint">Здесь видны текущий статус, недавние вызовы и трассировка действий. Ответ, перевод и завершение звонка остаются во всплывающем окне.</p>
|
||||
<div class="inline-form compact">
|
||||
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
|
||||
<button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button>
|
||||
@@ -551,7 +563,7 @@
|
||||
<div class="inline-form compact live-call-inspector">
|
||||
<select id="liveCallIdSelect"></select>
|
||||
</div>
|
||||
<p class="hint">Здесь остаётся технический слой живых и недавних звонков, а управление перенесено во всплывающее окно.</p>
|
||||
<p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
|
||||
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
|
||||
<div id="liveCallsTable" class="table"></div>
|
||||
<details class="details-block">
|
||||
@@ -603,6 +615,6 @@
|
||||
|
||||
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/app.js?v=track36-operator-ru-polish"></script>
|
||||
<script src="/operator/assets/app.js?v=track38-unified-inbox1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+32
-1
@@ -2968,6 +2968,32 @@ textarea::placeholder {
|
||||
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 {
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
}
|
||||
@@ -4967,7 +4993,8 @@ body.analytics-drilldown-open {
|
||||
.analytics-compare-head,
|
||||
.analytics-subhead,
|
||||
.analytics-chart-meta,
|
||||
.analytics-narrative-meta {
|
||||
.analytics-narrative-meta,
|
||||
.unified-inbox-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -4985,6 +5012,10 @@ body.analytics-drilldown-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.unified-inbox-board {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-spotlight-grid,
|
||||
.customer-spotlight-grid.page,
|
||||
.customer-profile-summary-grid,
|
||||
|
||||
Reference in New Issue
Block a user