botserver/src/attendant/ui.rs
Rodrigo Rodriguez 5ea171d126
Some checks failed
BotServer CI / build (push) Failing after 1m34s
Refactor: Split large files into modular subdirectories
Split 20+ files over 1000 lines into focused subdirectories for better
maintainability and code organization. All changes maintain backward
compatibility through re-export wrappers.

Major splits:
- attendance/llm_assist.rs (2074→7 modules)
- basic/keywords/face_api.rs → face_api/ (7 modules)
- basic/keywords/file_operations.rs → file_ops/ (8 modules)
- basic/keywords/hear_talk.rs → hearing/ (6 modules)
- channels/wechat.rs → wechat/ (10 modules)
- channels/youtube.rs → youtube/ (5 modules)
- contacts/mod.rs → contacts_api/ (6 modules)
- core/bootstrap/mod.rs → bootstrap/ (5 modules)
- core/shared/admin.rs → admin_*.rs (5 modules)
- designer/canvas.rs → canvas_api/ (6 modules)
- designer/mod.rs → designer_api/ (6 modules)
- docs/handlers.rs → handlers_api/ (11 modules)
- drive/mod.rs → drive_handlers.rs, drive_types.rs
- learn/mod.rs → types.rs
- main.rs → main_module/ (7 modules)
- meet/webinar.rs → webinar_api/ (8 modules)
- paper/mod.rs → (10 modules)
- security/auth.rs → auth_api/ (7 modules)
- security/passkey.rs → (4 modules)
- sources/mod.rs → sources_api/ (5 modules)
- tasks/mod.rs → task_api/ (5 modules)

Stats: 38,040 deletions, 1,315 additions across 318 files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 21:09:30 +00:00

623 lines
23 KiB
Rust

use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::core::bot::get_default_bot;
use crate::core::shared::schema::{
attendant_agent_status, attendant_queues, attendant_sessions,
};
use crate::core::shared::state::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct SessionListQuery {
pub status: Option<String>,
pub queue_id: Option<Uuid>,
pub agent_id: Option<Uuid>,
pub limit: Option<i64>,
}
pub async fn sessions_table(
State(state): State<Arc<AppState>>,
Query(query): Query<SessionListQuery>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
db_query = db_query.filter(attendant_sessions::status.eq(status));
}
if let Some(queue_id) = query.queue_id {
db_query = db_query.filter(attendant_sessions::queue_id.eq(queue_id));
}
if let Some(agent_id) = query.agent_id {
db_query = db_query.filter(attendant_sessions::agent_id.eq(agent_id));
}
db_query = db_query.order(attendant_sessions::created_at.desc());
if let Some(limit) = query.limit {
db_query = db_query.limit(limit);
} else {
db_query = db_query.limit(50);
}
db_query
.select((
attendant_sessions::id,
attendant_sessions::session_number,
attendant_sessions::customer_name,
attendant_sessions::customer_email,
attendant_sessions::channel,
attendant_sessions::status,
attendant_sessions::priority,
attendant_sessions::subject,
attendant_sessions::created_at,
))
.load::<(Uuid, String, Option<String>, Option<String>, String, String, i32, Option<String>, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(sessions) if !sessions.is_empty() => {
let rows: String = sessions
.iter()
.map(|(id, number, name, email, channel, status, priority, subject, created)| {
let customer = name.clone().unwrap_or_else(|| email.clone().unwrap_or_else(|| "Unknown".to_string()));
let subj = subject.clone().unwrap_or_else(|| "No subject".to_string());
let status_class = match status.as_str() {
"waiting" => "status-waiting",
"active" => "status-active",
"ended" => "status-ended",
_ => "status-default",
};
let priority_badge = match priority {
p if *p >= 2 => r##"<span class="badge badge-high">High</span>"##,
p if *p == 1 => r##"<span class="badge badge-medium">Medium</span>"##,
_ => r##"<span class="badge badge-low">Low</span>"##,
};
let time = created.format("%Y-%m-%d %H:%M").to_string();
format!(
r##"<tr class="session-row" data-id="{}" hx-get="/api/ui/attendant/sessions/{}" hx-target="#session-detail" hx-swap="innerHTML">
<td class="session-number">{}</td>
<td class="session-customer">{}</td>
<td class="session-channel"><span class="channel-badge channel-{}">{}</span></td>
<td class="session-subject">{}</td>
<td class="session-priority">{}</td>
<td class="session-status"><span class="{}">{}</span></td>
<td class="session-time">{}</td>
</tr>"##,
id, id, number, customer, channel, channel, subj, priority_badge, status_class, status, time
)
})
.collect();
Html(format!(
r##"<table class="sessions-table">
<thead>
<tr>
<th>Session #</th>
<th>Customer</th>
<th>Channel</th>
<th>Subject</th>
<th>Priority</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>{}</tbody>
</table>"##,
rows
))
}
_ => Html(
r##"<div class="empty-state">
<p>No sessions found</p>
</div>"##
.to_string(),
),
}
}
pub async fn sessions_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(result.unwrap_or(0).to_string())
}
pub async fn waiting_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(result.unwrap_or(0).to_string())
}
pub async fn active_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(result.unwrap_or(0).to_string())
}
pub async fn agents_online_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.filter(attendant_agent_status::status.eq("online"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(result.unwrap_or(0).to_string())
}
pub async fn session_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
attendant_sessions::table
.find(id)
.select((
attendant_sessions::id,
attendant_sessions::session_number,
attendant_sessions::customer_name,
attendant_sessions::customer_email,
attendant_sessions::customer_phone,
attendant_sessions::channel,
attendant_sessions::status,
attendant_sessions::priority,
attendant_sessions::subject,
attendant_sessions::initial_message,
attendant_sessions::notes,
attendant_sessions::created_at,
attendant_sessions::assigned_at,
attendant_sessions::ended_at,
))
.first::<(
Uuid,
String,
Option<String>,
Option<String>,
Option<String>,
String,
String,
i32,
Option<String>,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<DateTime<Utc>>,
Option<DateTime<Utc>>,
)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some((id, number, name, email, phone, channel, status, priority, subject, message, notes, created, assigned, ended)) => {
let customer_name = name.unwrap_or_else(|| "Unknown".to_string());
let customer_email = email.unwrap_or_else(|| "-".to_string());
let customer_phone = phone.unwrap_or_else(|| "-".to_string());
let subject_text = subject.unwrap_or_else(|| "No subject".to_string());
let message_text = message.unwrap_or_else(|| "-".to_string());
let notes_text = notes.unwrap_or_else(|| "-".to_string());
let created_time = created.format("%Y-%m-%d %H:%M:%S").to_string();
let assigned_time = assigned.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "-".to_string());
let ended_time = ended.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "-".to_string());
let priority_text = match priority {
p if p >= 2 => "High",
p if p == 1 => "Medium",
_ => "Low",
};
Html(format!(
r##"<div class="session-detail-card">
<div class="detail-header">
<h3>Session #{}</h3>
<span class="status-badge status-{}">{}</span>
</div>
<div class="detail-section">
<h4>Customer Information</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Name</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Email</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Phone</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Channel</label>
<span class="channel-badge channel-{}">{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Session Details</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Subject</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Priority</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Created</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Assigned</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Ended</label>
<span>{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Initial Message</h4>
<p class="message-content">{}</p>
</div>
<div class="detail-section">
<h4>Notes</h4>
<p class="notes-content">{}</p>
</div>
<div class="detail-actions">
<button class="btn btn-primary" hx-put="/api/attendant/sessions/{}/assign" hx-swap="none">Assign to Me</button>
<button class="btn btn-secondary" hx-get="/api/ui/attendant/sessions/{}/messages" hx-target="#messages-panel">View Messages</button>
<button class="btn btn-danger" hx-put="/api/attendant/sessions/{}/end" hx-swap="none">End Session</button>
</div>
</div>"##,
number, status, status,
customer_name, customer_email, customer_phone,
channel, channel,
subject_text, priority_text,
created_time, assigned_time, ended_time,
message_text, notes_text,
id, id, id
))
}
None => Html(
r##"<div class="empty-state">
<p>Session not found</p>
</div>"##
.to_string(),
),
}
}
pub async fn queues_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_queues::table
.filter(attendant_queues::bot_id.eq(bot_id))
.filter(attendant_queues::is_active.eq(true))
.order(attendant_queues::priority.desc())
.select((
attendant_queues::id,
attendant_queues::name,
attendant_queues::description,
attendant_queues::priority,
))
.load::<(Uuid, String, Option<String>, i32)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(queues) if !queues.is_empty() => {
let items: String = queues
.iter()
.map(|(id, name, desc, priority)| {
let description = desc.clone().unwrap_or_default();
format!(
r##"<div class="queue-item" data-id="{}">
<div class="queue-header">
<span class="queue-name">{}</span>
<span class="queue-priority">Priority: {}</span>
</div>
<p class="queue-description">{}</p>
<div class="queue-stats" hx-get="/api/ui/attendant/queues/{}/stats" hx-trigger="load" hx-swap="innerHTML"></div>
</div>"##,
id, name, priority, description, id
)
})
.collect();
Html(format!(r##"<div class="queues-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="empty-state">
<p>No queues configured</p>
</div>"##
.to_string(),
),
}
}
pub async fn queue_stats(
State(state): State<Arc<AppState>>,
Path(queue_id): Path<Uuid>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let waiting: i64 = attendant_sessions::table
.filter(attendant_sessions::queue_id.eq(queue_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = attendant_sessions::table
.filter(attendant_sessions::queue_id.eq(queue_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((waiting, active))
})
.await
.ok()
.flatten();
match result {
Some((waiting, active)) => Html(format!(
r##"<span class="stat">Waiting: {}</span>
<span class="stat">Active: {}</span>"##,
waiting, active
)),
None => Html(r##"<span class="stat">-</span>"##.to_string()),
}
}
pub async fn agent_status_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.order(attendant_agent_status::status.asc())
.select((
attendant_agent_status::id,
attendant_agent_status::agent_id,
attendant_agent_status::status,
attendant_agent_status::current_sessions,
attendant_agent_status::max_sessions,
attendant_agent_status::last_activity_at,
))
.load::<(Uuid, Uuid, String, i32, i32, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(agents) if !agents.is_empty() => {
let items: String = agents
.iter()
.map(|(id, agent_id, status, current, max, last_activity)| {
let status_class = match status.as_str() {
"online" => "status-online",
"busy" => "status-busy",
"break" => "status-break",
_ => "status-offline",
};
let last_time = last_activity.format("%H:%M").to_string();
format!(
r##"<div class="agent-item" data-id="{}">
<div class="agent-avatar">
<span class="status-indicator {}"></span>
</div>
<div class="agent-info">
<span class="agent-name">Agent {}</span>
<span class="agent-status">{}</span>
</div>
<div class="agent-load">
<span>{}/{} sessions</span>
<span class="last-activity">Last: {}</span>
</div>
</div>"##,
id, status_class, &agent_id.to_string()[..8], status, current, max, last_time
)
})
.collect();
Html(format!(r##"<div class="agents-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="empty-state">
<p>No agents found</p>
</div>"##
.to_string(),
),
}
}
pub async fn dashboard_stats(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let today = Utc::now().date_naive();
let today_start = today.and_hms_opt(0, 0, 0)?;
let total_today: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::created_at.ge(today_start))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let waiting: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let agents_online: i64 = attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.filter(attendant_agent_status::status.eq("online"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((total_today, waiting, active, agents_online))
})
.await
.ok()
.flatten();
match result {
Some((total, waiting, active, agents)) => Html(format!(
r##"<div class="dashboard-stats">
<div class="stat-card">
<span class="stat-value">{}</span>
<span class="stat-label">Sessions Today</span>
</div>
<div class="stat-card stat-warning">
<span class="stat-value">{}</span>
<span class="stat-label">Waiting</span>
</div>
<div class="stat-card stat-success">
<span class="stat-value">{}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card stat-info">
<span class="stat-value">{}</span>
<span class="stat-label">Agents Online</span>
</div>
</div>"##,
total, waiting, active, agents
)),
None => Html(
r##"<div class="dashboard-stats">
<div class="stat-card"><span class="stat-value">-</span></div>
</div>"##
.to_string(),
),
}
}
pub fn configure_attendant_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/attendant/sessions", get(sessions_table))
.route("/api/ui/attendant/sessions/count", get(sessions_count))
.route("/api/ui/attendant/sessions/waiting", get(waiting_count))
.route("/api/ui/attendant/sessions/active", get(active_count))
.route("/api/ui/attendant/sessions/:id", get(session_detail))
.route("/api/ui/attendant/queues", get(queues_list))
.route("/api/ui/attendant/queues/:id/stats", get(queue_stats))
.route("/api/ui/attendant/agents", get(agent_status_list))
.route("/api/ui/attendant/agents/online", get(agents_online_count))
.route("/api/ui/attendant/dashboard", get(dashboard_stats))
}