feat: surface L2 agent pool and escalations on supervisor screen
- routing-service: GET /escalations (list recent escalation attempts) - supervisor UI: new panel showing the real agent pool (status, level, tenant, skills, calls handled) fed by GET /agents, with a form to add operators to the pool - supervisor UI: new live escalation feed (AI->L2 handoffs, status, assigned operator), auto-refreshed every 5s alongside live calls
This commit is contained in:
@@ -12,6 +12,7 @@ from services.shared.models import (
|
|||||||
AgentCreate,
|
AgentCreate,
|
||||||
AgentPoolOut,
|
AgentPoolOut,
|
||||||
AgentStatusUpdateIn,
|
AgentStatusUpdateIn,
|
||||||
|
EscalationOut,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
QueueCreate,
|
QueueCreate,
|
||||||
QueueOut,
|
QueueOut,
|
||||||
@@ -20,7 +21,7 @@ from services.shared.models import (
|
|||||||
)
|
)
|
||||||
from services.shared.security import require_roles
|
from services.shared.security import require_roles
|
||||||
from services.shared.sql_init import init_sql_schema
|
from services.shared.sql_init import init_sql_schema
|
||||||
from services.shared.sql_models import AgentRow, IvrSessionRow, Queue, RoutingCounter
|
from services.shared.sql_models import AgentRow, EscalationRow, IvrSessionRow, Queue, RoutingCounter
|
||||||
|
|
||||||
app = FastAPI(title="routing-service", version="1.1.0")
|
app = FastAPI(title="routing-service", version="1.1.0")
|
||||||
|
|
||||||
@@ -328,6 +329,43 @@ def update_agent_status(
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _escalation_to_out(row: EscalationRow) -> EscalationOut:
|
||||||
|
return EscalationOut(
|
||||||
|
escalation_id=row.escalation_id,
|
||||||
|
call_id=row.call_id,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
from_level=row.from_level,
|
||||||
|
to_level=row.to_level,
|
||||||
|
reason_code=row.reason_code,
|
||||||
|
required_skills=routing_engine.parse_list_json(row.required_skills_json),
|
||||||
|
priority=row.priority,
|
||||||
|
topic=row.topic,
|
||||||
|
summary=row.summary,
|
||||||
|
status=row.status,
|
||||||
|
assigned_agent_id=row.assigned_agent_id,
|
||||||
|
requested_at=row.requested_at,
|
||||||
|
connected_at=row.connected_at,
|
||||||
|
completed_at=row.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/escalations", response_model=list[EscalationOut])
|
||||||
|
def list_escalations(
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
||||||
|
) -> list[EscalationOut]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
stmt = select(EscalationRow).order_by(EscalationRow.id.desc()).limit(max(1, min(limit, 200)))
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(EscalationRow.status == status)
|
||||||
|
rows = session.execute(stmt).scalars().all()
|
||||||
|
return [_escalation_to_out(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/internal/routing/reserve-agent", response_model=RoutingAgentReserveOut)
|
@app.post("/internal/routing/reserve-agent", response_model=RoutingAgentReserveOut)
|
||||||
def reserve_agent_endpoint(
|
def reserve_agent_endpoint(
|
||||||
payload: RoutingAgentReserveIn,
|
payload: RoutingAgentReserveIn,
|
||||||
|
|||||||
@@ -5987,3 +5987,66 @@ body.analytics-drilldown-open {
|
|||||||
padding: 10px 16px 0;
|
padding: 10px 16px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-pool-item,
|
||||||
|
.escalation-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-pool-item strong,
|
||||||
|
.escalation-item strong {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-warning {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-muted {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-pool-meta,
|
||||||
|
.escalation-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|||||||
+189
-1
@@ -698,6 +698,178 @@ async function setAgentState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AGENT_STATUS_LABELS = {
|
||||||
|
AVAILABLE: 'Свободен',
|
||||||
|
RESERVED: 'Зарезервирован',
|
||||||
|
RINGING: 'Звонок идёт',
|
||||||
|
TALKING: 'На линии',
|
||||||
|
AFTER_CALL_WORK: 'Постобработка',
|
||||||
|
PAUSED: 'Пауза',
|
||||||
|
OFFLINE: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_STATUS_TONE = {
|
||||||
|
AVAILABLE: 'is-success',
|
||||||
|
RESERVED: 'is-warning',
|
||||||
|
RINGING: 'is-warning',
|
||||||
|
TALKING: 'is-success',
|
||||||
|
AFTER_CALL_WORK: 'is-warning',
|
||||||
|
PAUSED: 'is-muted',
|
||||||
|
OFFLINE: 'is-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCALATION_STATUS_LABELS = {
|
||||||
|
requested: 'Запрошена',
|
||||||
|
ringing: 'Дозвон оператора',
|
||||||
|
completed: 'Завершена',
|
||||||
|
failed: 'Не удалась',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCALATION_STATUS_TONE = {
|
||||||
|
requested: 'is-warning',
|
||||||
|
ringing: 'is-warning',
|
||||||
|
completed: 'is-success',
|
||||||
|
failed: 'is-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusBadge(labels, tones, value) {
|
||||||
|
const label = labels[value] || value || 'неизвестно';
|
||||||
|
const tone = tones[value] || 'is-muted';
|
||||||
|
return `<span class="status-badge ${tone}">${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCallAgents(items) {
|
||||||
|
const byStatus = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
$('callAgentsSummary').innerHTML = [
|
||||||
|
renderSummaryCard('Всего в пуле', items.length, 'Операторы L2/L3'),
|
||||||
|
renderSummaryCard('Свободны', byStatus.AVAILABLE || 0, 'Могут принять звонок сейчас'),
|
||||||
|
renderSummaryCard('На линии', (byStatus.TALKING || 0) + (byStatus.RINGING || 0) + (byStatus.RESERVED || 0), 'Заняты звонком или дозвоном'),
|
||||||
|
renderSummaryCard('Offline', byStatus.OFFLINE || 0, 'Недоступны'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
$('callAgentsList').innerHTML = '<li class="empty-state">Пул пуст. Добавьте оператора ниже.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('callAgentsList').innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li class="agent-pool-item">
|
||||||
|
<strong>${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}</strong>
|
||||||
|
${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
|
||||||
|
<span class="status-badge is-muted">${escapeHtml(item.level)}</span>
|
||||||
|
${item.enabled ? '' : '<span class="status-badge is-danger">отключён</span>'}
|
||||||
|
<span class="agent-pool-meta">tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}</span>
|
||||||
|
<span class="agent-pool-meta">навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}</span>
|
||||||
|
<span class="agent-pool-meta">принято звонков: ${Number(item.calls_handled_count || 0)}</span>
|
||||||
|
${item.current_call_id ? `<span class="agent-pool-meta">текущий звонок: ${escapeHtml(item.current_call_id)}</span>` : ''}
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCallAgents(silent = false) {
|
||||||
|
try {
|
||||||
|
const data = await api('routing', 'agents');
|
||||||
|
renderCallAgents(Array.isArray(data) ? data : []);
|
||||||
|
if (!silent) {
|
||||||
|
log('Пул L2-операторов обновлён', { count: data.length });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$('callAgentsList').innerHTML = '<li class="empty-state">Не удалось загрузить пул операторов.</li>';
|
||||||
|
if (!silent) {
|
||||||
|
log('Не удалось загрузить пул операторов', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCallAgent() {
|
||||||
|
try {
|
||||||
|
const extension = $('newAgentExtension').value.trim();
|
||||||
|
if (!extension) {
|
||||||
|
log('Укажите внутренний номер оператора');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skills = $('newAgentSkills').value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const tenantId = $('newAgentTenant').value.trim();
|
||||||
|
const data = await api('routing', 'agents', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
extension,
|
||||||
|
display_name: $('newAgentDisplayName').value.trim() || `Оператор ${extension}`,
|
||||||
|
level: $('newAgentLevel').value,
|
||||||
|
tenant_ids: tenantId ? [tenantId] : [],
|
||||||
|
skills,
|
||||||
|
max_concurrent_calls: 1,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
log('Оператор добавлен в пул', { agent_id: data.agent_id, extension: data.extension });
|
||||||
|
$('newAgentExtension').value = '';
|
||||||
|
$('newAgentDisplayName').value = '';
|
||||||
|
$('newAgentSkills').value = '';
|
||||||
|
await loadCallAgents();
|
||||||
|
} catch (err) {
|
||||||
|
log('Не удалось добавить оператора', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEscalations(items) {
|
||||||
|
const byStatus = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
$('escalationsSummary').innerHTML = [
|
||||||
|
renderSummaryCard('Активных', (byStatus.requested || 0) + (byStatus.ringing || 0), 'Ждут/дозваниваются до оператора'),
|
||||||
|
renderSummaryCard('Завершено', byStatus.completed || 0, 'Успешно приняты оператором'),
|
||||||
|
renderSummaryCard('Не удалось', byStatus.failed || 0, 'Свободного оператора не нашлось'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
$('escalationsList').innerHTML = '<li class="empty-state">Эскалаций пока не было.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('escalationsList').innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li class="escalation-item">
|
||||||
|
<strong>звонок ${escapeHtml(item.call_id)}</strong>
|
||||||
|
${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
|
||||||
|
<span class="status-badge is-muted">${escapeHtml(item.from_level)} → ${escapeHtml(item.to_level)}</span>
|
||||||
|
<span class="escalation-meta">причина: ${escapeHtml(item.reason_code || '-')}</span>
|
||||||
|
<span class="escalation-meta">оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}</span>
|
||||||
|
<span class="escalation-meta">запрошена: ${formatTime(item.requested_at)}</span>
|
||||||
|
${item.summary ? `<span class="escalation-meta">тема: ${escapeHtml(item.summary)}</span>` : ''}
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEscalations(silent = false) {
|
||||||
|
try {
|
||||||
|
const data = await api('routing', 'escalations?limit=30');
|
||||||
|
renderEscalations(Array.isArray(data) ? data : []);
|
||||||
|
if (!silent) {
|
||||||
|
log('Лента эскалаций обновлена', { count: data.length });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$('escalationsList').innerHTML = '<li class="empty-state">Не удалось загрузить эскалации.</li>';
|
||||||
|
if (!silent) {
|
||||||
|
log('Не удалось загрузить эскалации', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveQueueMetrics() {
|
async function saveQueueMetrics() {
|
||||||
try {
|
try {
|
||||||
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
||||||
@@ -973,7 +1145,16 @@ 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 Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]);
|
await Promise.all([
|
||||||
|
loadRealtime(),
|
||||||
|
loadKpi(),
|
||||||
|
loadLiveCalls(),
|
||||||
|
loadAgents(),
|
||||||
|
loadCallAgents(),
|
||||||
|
loadEscalations(),
|
||||||
|
loadRecordings(),
|
||||||
|
refreshKnowledgeBase(true),
|
||||||
|
]);
|
||||||
log('Данные супервизора обновлены');
|
log('Данные супервизора обновлены');
|
||||||
});
|
});
|
||||||
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
|
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
|
||||||
@@ -981,6 +1162,9 @@ function wire() {
|
|||||||
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
|
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
|
||||||
$('loadAgentsBtn').addEventListener('click', loadAgents);
|
$('loadAgentsBtn').addEventListener('click', loadAgents);
|
||||||
$('setAgentBtn').addEventListener('click', setAgentState);
|
$('setAgentBtn').addEventListener('click', setAgentState);
|
||||||
|
$('loadCallAgentsBtn').addEventListener('click', () => loadCallAgents());
|
||||||
|
$('createCallAgentBtn').addEventListener('click', createCallAgent);
|
||||||
|
$('loadEscalationsBtn').addEventListener('click', () => loadEscalations());
|
||||||
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
|
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
|
||||||
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
|
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
|
||||||
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
|
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
|
||||||
@@ -1015,11 +1199,15 @@ async function init() {
|
|||||||
loadKpi(),
|
loadKpi(),
|
||||||
loadLiveCalls(true),
|
loadLiveCalls(true),
|
||||||
loadAgents(),
|
loadAgents(),
|
||||||
|
loadCallAgents(true),
|
||||||
|
loadEscalations(true),
|
||||||
loadRecordings(),
|
loadRecordings(),
|
||||||
refreshKnowledgeBase(true),
|
refreshKnowledgeBase(true),
|
||||||
]);
|
]);
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
void loadLiveCalls(true);
|
void loadLiveCalls(true);
|
||||||
|
void loadCallAgents(true);
|
||||||
|
void loadEscalations(true);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
log('Пульт супервизора готов');
|
log('Пульт супервизора готов');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
|
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
|
||||||
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
|
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
|
||||||
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
|
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
|
||||||
|
<a class="nav-link" href="#callRoutingAgents"><span class="nav-bullet"></span>Пул L2 (звонки)</a>
|
||||||
|
<a class="nav-link" href="#escalations"><span class="nav-bullet"></span>Эскалации</a>
|
||||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
||||||
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +155,45 @@
|
|||||||
<ul id="agentsList" class="list"></ul>
|
<ul id="agentsList" class="list"></ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="panel reveal" id="callRoutingAgents">
|
||||||
|
<h2>Пул операторов L2 (звонки)</h2>
|
||||||
|
<p class="hint">Реальный пул для эскалации звонков с Voice AI (L1) на живых операторов +7 747 645 6048. Отдельно от демо-статусов в блоке «Агенты» выше — здесь показан фактический пул Routing Engine.</p>
|
||||||
|
<div class="actions compact-actions">
|
||||||
|
<button id="loadCallAgentsBtn" class="btn ghost">Обновить пул</button>
|
||||||
|
</div>
|
||||||
|
<div id="callAgentsSummary" class="summary-grid"></div>
|
||||||
|
<ul id="callAgentsList" class="list"></ul>
|
||||||
|
|
||||||
|
<details class="details-block">
|
||||||
|
<summary>Добавить оператора в пул</summary>
|
||||||
|
<div class="inline-form split-two">
|
||||||
|
<input id="newAgentExtension" placeholder="Внутренний номер (напр. 2001)" />
|
||||||
|
<input id="newAgentDisplayName" placeholder="Имя оператора" />
|
||||||
|
</div>
|
||||||
|
<div class="inline-form split-two">
|
||||||
|
<select id="newAgentLevel">
|
||||||
|
<option value="L2" selected>L2 — оператор</option>
|
||||||
|
<option value="L3">L3 — технический специалист</option>
|
||||||
|
</select>
|
||||||
|
<input id="newAgentTenant" placeholder="Tenant ID" value="konturai" />
|
||||||
|
</div>
|
||||||
|
<div class="inline-form compact">
|
||||||
|
<input id="newAgentSkills" placeholder="Навыки через запятую (необязательно)" />
|
||||||
|
<button id="createCallAgentBtn" class="btn">Добавить в пул</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel reveal" id="escalations">
|
||||||
|
<h2>Эскалации AI → оператор</h2>
|
||||||
|
<p class="hint">Живая лента: какие звонки Voice AI передал(-ает) на L2 и кто из операторов их принял.</p>
|
||||||
|
<div class="actions compact-actions">
|
||||||
|
<button id="loadEscalationsBtn" class="btn ghost">Обновить эскалации</button>
|
||||||
|
</div>
|
||||||
|
<div id="escalationsSummary" class="summary-grid"></div>
|
||||||
|
<ul id="escalationsList" class="list"></ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="panel reveal" id="queues">
|
<article class="panel reveal" id="queues">
|
||||||
<h2>Очередь</h2>
|
<h2>Очередь</h2>
|
||||||
<div class="inline-form split-two">
|
<div class="inline-form split-two">
|
||||||
|
|||||||
Reference in New Issue
Block a user