Some checks failed
BotServer CI / build (push) Failing after 7m5s
751 lines
26 KiB
Rust
751 lines
26 KiB
Rust
use axum::{
|
||
extract::{Query, State},
|
||
response::{Html, IntoResponse},
|
||
routing::get,
|
||
Router,
|
||
};
|
||
use diesel::prelude::*;
|
||
use serde::Deserialize;
|
||
use std::sync::Arc;
|
||
use uuid::Uuid;
|
||
|
||
use crate::core::bot::get_default_bot;
|
||
use crate::contacts::crm::{CrmAccount, CrmContact, CrmDeal, CrmLead, CrmOpportunity};
|
||
use crate::core::shared::schema::{crm_accounts, crm_contacts, crm_leads, crm_opportunities};
|
||
use crate::core::shared::state::AppState;
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct StageQuery {
|
||
pub stage: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SearchQuery {
|
||
pub q: Option<String>,
|
||
}
|
||
|
||
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
|
||
use diesel::prelude::*;
|
||
use crate::core::shared::schema::bots;
|
||
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return (Uuid::nil(), Uuid::nil());
|
||
};
|
||
let (bot_id, _bot_name) = get_default_bot(&mut conn);
|
||
|
||
// Get org_id using diesel query
|
||
let bot_org_id = bots::table
|
||
.filter(bots::id.eq(bot_id))
|
||
.select(bots::org_id)
|
||
.first::<Option<Uuid>>(&mut conn)
|
||
.unwrap_or(None)
|
||
.unwrap_or(Uuid::nil());
|
||
|
||
(bot_org_id, bot_id)
|
||
}
|
||
|
||
pub fn configure_crm_routes() -> Router<Arc<AppState>> {
|
||
Router::new()
|
||
.route("/api/ui/crm/count", get(handle_crm_count))
|
||
.route("/api/ui/crm/pipeline", get(handle_crm_pipeline))
|
||
.route("/api/ui/crm/leads", get(handle_crm_leads))
|
||
.route("/api/ui/crm/leads/:id", get(handle_lead_detail))
|
||
.route("/api/ui/crm/opportunities", get(handle_crm_opportunities))
|
||
.route("/api/ui/crm/deals", get(handle_crm_deals))
|
||
.route("/api/ui/crm/contacts", get(handle_crm_contacts))
|
||
.route("/api/ui/crm/accounts", get(handle_crm_accounts))
|
||
.route("/api/ui/crm/search", get(handle_crm_search))
|
||
.route("/api/ui/crm/stats/conversion-rate", get(handle_conversion_rate))
|
||
.route("/api/ui/crm/stats/pipeline-value", get(handle_pipeline_value))
|
||
.route("/api/ui/crm/stats/avg-deal", get(handle_avg_deal))
|
||
.route("/api/ui/crm/stats/won-month", get(handle_won_month))
|
||
}
|
||
|
||
async fn handle_crm_count(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(query): Query<StageQuery>,
|
||
) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("0".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
let stage = query.stage.unwrap_or_else(|| "all".to_string());
|
||
|
||
let count: i64 = if stage == "all" {
|
||
crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.count()
|
||
.get_result(&mut conn)
|
||
.unwrap_or(0)
|
||
} else {
|
||
crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.filter(crm_leads::stage.eq(&stage))
|
||
.count()
|
||
.get_result(&mut conn)
|
||
.unwrap_or(0)
|
||
};
|
||
|
||
Html(count.to_string())
|
||
}
|
||
|
||
async fn handle_crm_pipeline(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(query): Query<StageQuery>,
|
||
) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_pipeline("lead"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
let stage = query.stage.unwrap_or_else(|| "new".to_string());
|
||
|
||
let leads: Vec<CrmLead> = crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.filter(crm_leads::stage.eq(&stage))
|
||
.order(crm_leads::created_at.desc())
|
||
.limit(20)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if leads.is_empty() {
|
||
return Html(render_empty_pipeline(&stage));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for lead in leads {
|
||
let value_str = lead
|
||
.value
|
||
.map(|v| format!("${}", v))
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let contact_name = lead.contact_id.map(|_| "Contact").unwrap_or("-");
|
||
|
||
html.push_str(&format!(
|
||
"<div class=\"pipeline-card\" data-id=\"{}\">
|
||
<div class=\"pipeline-card-header\">
|
||
<span class=\"lead-title\">{}</span>
|
||
<span class=\"lead-value\">{}</span>
|
||
</div>
|
||
<div class=\"pipeline-card-body\">
|
||
<span class=\"lead-contact\">{}</span>
|
||
<span class=\"lead-probability\">{}%</span>
|
||
</div>
|
||
<div class=\"pipeline-card-actions\">
|
||
<button class=\"btn-sm\" hx-put=\"/api/crm/leads/{}/stage?stage=qualified\" hx-swap=\"none\">Qualify</button>
|
||
<button class=\"btn-sm btn-accent\" hx-post=\"/api/crm/leads/{}/convert\" hx-swap=\"none\">Convert</button>
|
||
<button class=\"btn-sm btn-secondary\" hx-get=\"/api/ui/crm/leads/{}\" hx-target=\"#detail-panel\">View</button>
|
||
</div>
|
||
</div>",
|
||
lead.id,
|
||
html_escape(&lead.title),
|
||
value_str,
|
||
contact_name,
|
||
lead.probability,
|
||
lead.id,
|
||
lead.id,
|
||
lead.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_leads(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_table("leads", "📋", "No leads yet", "Create your first lead to get started"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let leads: Vec<CrmLead> = crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.filter(crm_leads::closed_at.is_null())
|
||
.order(crm_leads::created_at.desc())
|
||
.limit(50)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if leads.is_empty() {
|
||
return Html(render_empty_table("leads", "📋", "No leads yet", "Create your first lead to get started"));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for lead in leads {
|
||
let value_str = lead
|
||
.value
|
||
.map(|v| format!("${}", v))
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let expected_close = lead
|
||
.expected_close_date
|
||
.map(|d| d.to_string())
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let source = lead.source.as_deref().unwrap_or("-");
|
||
|
||
html.push_str(&format!(
|
||
"<tr class=\"lead-row\" data-id=\"{}\">
|
||
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
|
||
<td class=\"lead-title\">{}</td>
|
||
<td>{}</td>
|
||
<td><span class=\"stage-badge stage-{}\">{}</span></td>
|
||
<td>{}%</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td class=\"actions\">
|
||
<button class=\"btn-icon\" hx-get=\"/api/crm/leads/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
|
||
<button class=\"btn-icon\" hx-delete=\"/api/crm/leads/{}\" hx-confirm=\"Delete this lead?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
|
||
</td>
|
||
</tr>",
|
||
lead.id,
|
||
lead.id,
|
||
html_escape(&lead.title),
|
||
value_str,
|
||
lead.stage,
|
||
lead.stage,
|
||
lead.probability,
|
||
expected_close,
|
||
source,
|
||
lead.id,
|
||
lead.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
use axum::extract::Path;
|
||
|
||
async fn handle_lead_detail(
|
||
State(state): State<Arc<AppState>>,
|
||
Path(id): Path<Uuid>,
|
||
) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("<div class='detail-error'>Database error</div>".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let lead = match crm_leads::table
|
||
.filter(crm_leads::id.eq(id))
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.first::<CrmLead>(&mut conn)
|
||
.optional()
|
||
{
|
||
Ok(Some(lead)) => lead,
|
||
_ => return Html("<div class='detail-error'>Lead not found</div>".to_string()),
|
||
};
|
||
|
||
let mut html = String::new();
|
||
html.push_str("<div class='detail-header'><h3>");
|
||
html.push_str(&html_escape(&lead.title));
|
||
html.push_str("</h3><button class='detail-close' onclick=\"document.getElementById('detail-panel').classList.remove('open')\">×</button></div><div class='detail-body'>");
|
||
|
||
let value_str = lead.value.map(|v| format!("${}", v)).unwrap_or_else(|| "-".to_string());
|
||
html.push_str("<div class='detail-field'><label>Value:</label><span>");
|
||
html.push_str(&value_str);
|
||
html.push_str("</span></div>");
|
||
|
||
html.push_str("<div class='detail-field'><label>Stage:</label><span class='stage-badge stage-");
|
||
html.push_str(&lead.stage);
|
||
html.push_str("'>");
|
||
html.push_str(&lead.stage);
|
||
html.push_str("</span></div>");
|
||
|
||
let source = lead.source.as_deref().unwrap_or("-");
|
||
html.push_str("<div class='detail-field'><label>Source:</label><span>");
|
||
html.push_str(source);
|
||
html.push_str("</span></div>");
|
||
|
||
html.push_str("<div class='detail-field'><label>Probability:</label><span>");
|
||
html.push_str(&lead.probability.to_string());
|
||
html.push_str("%</span></div>");
|
||
|
||
let description = lead.description.as_deref().unwrap_or("-");
|
||
html.push_str("<div class='detail-field'><label>Description:</label><span>");
|
||
html.push_str(&html_escape(description));
|
||
html.push_str("</span></div>");
|
||
|
||
let created = lead.created_at.format("%Y-%m-%d %H:%M").to_string();
|
||
html.push_str("<div class='detail-field'><label>Created:</label><span>");
|
||
html.push_str(&created);
|
||
html.push_str("</span></div>");
|
||
|
||
html.push_str("</div><div class='detail-actions'>");
|
||
html.push_str("<button class='btn-sm'>Edit</button>");
|
||
html.push_str("<button class='btn-sm btn-danger'>Delete</button>");
|
||
html.push_str("</div>");
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_opportunities(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_table("opportunities", "💼", "No opportunities yet", "Qualify leads to create opportunities"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let opportunities: Vec<CrmOpportunity> = crm_opportunities::table
|
||
.filter(crm_opportunities::org_id.eq(org_id))
|
||
.filter(crm_opportunities::bot_id.eq(bot_id))
|
||
.filter(crm_opportunities::won.is_null())
|
||
.order(crm_opportunities::created_at.desc())
|
||
.limit(50)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if opportunities.is_empty() {
|
||
return Html(render_empty_table("opportunities", "💼", "No opportunities yet", "Qualify leads to create opportunities"));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for opp in opportunities {
|
||
let value_str = opp
|
||
.value
|
||
.map(|v| format!("${}", v))
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let expected_close = opp
|
||
.expected_close_date
|
||
.map(|d| d.to_string())
|
||
.unwrap_or_else(|| "-".to_string());
|
||
|
||
html.push_str(&format!(
|
||
"<tr class=\"opportunity-row\" data-id=\"{}\">
|
||
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
|
||
<td class=\"opp-name\">{}</td>
|
||
<td>{}</td>
|
||
<td><span class=\"stage-badge stage-{}\">{}</span></td>
|
||
<td>{}%</td>
|
||
<td>{}</td>
|
||
<td class=\"actions\">
|
||
<button class=\"btn-icon btn-success\" hx-post=\"/api/crm/opportunities/{}/close\" hx-vals='{{\"won\":true}}' hx-swap=\"none\" title=\"Won\">✓</button>
|
||
<button class=\"btn-icon btn-danger\" hx-post=\"/api/crm/opportunities/{}/close\" hx-vals='{{\"won\":false}}' hx-swap=\"none\" title=\"Lost\">✗</button>
|
||
<button class=\"btn-icon\" hx-get=\"/api/crm/opportunities/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
|
||
</td>
|
||
</tr>",
|
||
opp.id,
|
||
opp.id,
|
||
html_escape(&opp.name),
|
||
value_str,
|
||
opp.stage,
|
||
opp.stage,
|
||
opp.probability,
|
||
expected_close,
|
||
opp.id,
|
||
opp.id,
|
||
opp.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_deals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
use crate::core::shared::schema::crm_deals;
|
||
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_table("deals", "💰", "No deals yet", "Create your first deal"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let deals: Vec<CrmDeal> = crm_deals::table
|
||
.filter(crm_deals::org_id.eq(org_id))
|
||
.filter(crm_deals::bot_id.eq(bot_id))
|
||
.order(crm_deals::created_at.desc())
|
||
.limit(50)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if deals.is_empty() {
|
||
return Html(render_empty_table("deals", "💰", "No deals yet", "Create your first deal"));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for deal in deals {
|
||
let value_str = deal
|
||
.value
|
||
.map(|v| format!("${}", v))
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let expected_close = deal
|
||
.expected_close_date
|
||
.map(|d: chrono::NaiveDate| d.to_string())
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let stage = deal.stage.as_deref().unwrap_or("new");
|
||
let probability = deal.probability;
|
||
|
||
html.push_str(&format!(
|
||
"<tr class=\"deal-row\" data-id=\"{}\">
|
||
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
|
||
<td class=\"deal-title\">{}</td>
|
||
<td>{}</td>
|
||
<td><span class=\"stage-badge stage-{}\">{}</span></td>
|
||
<td>{}%</td>
|
||
<td>{}</td>
|
||
<td class=\"actions\">
|
||
<button class=\"btn-icon\" hx-get=\"/api/crm/deals/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
|
||
</td>
|
||
</tr>",
|
||
deal.id,
|
||
deal.id,
|
||
html_escape(&deal.title.clone().unwrap_or_default()),
|
||
value_str,
|
||
stage,
|
||
stage,
|
||
probability,
|
||
expected_close,
|
||
deal.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_contacts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_table("contacts", "👥", "No contacts yet", "Add contacts to your CRM"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let contacts: Vec<CrmContact> = crm_contacts::table
|
||
.filter(crm_contacts::org_id.eq(org_id))
|
||
.filter(crm_contacts::bot_id.eq(bot_id))
|
||
.order(crm_contacts::created_at.desc())
|
||
.limit(50)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if contacts.is_empty() {
|
||
return Html(render_empty_table("contacts", "👥", "No contacts yet", "Add contacts to your CRM"));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for contact in contacts {
|
||
let name = format!(
|
||
"{} {}",
|
||
contact.first_name.as_deref().unwrap_or(""),
|
||
contact.last_name.as_deref().unwrap_or("")
|
||
).trim().to_string();
|
||
let name = if name.is_empty() { "-".to_string() } else { name };
|
||
let email = contact.email.as_deref().unwrap_or("-");
|
||
let phone = contact.phone.as_deref().unwrap_or("-");
|
||
let company = contact.company.as_deref().unwrap_or("-");
|
||
|
||
html.push_str(&format!(
|
||
"<tr class=\"contact-row\" data-id=\"{}\">
|
||
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
|
||
<td class=\"contact-name\">{}</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td><span class=\"status-badge status-{}\">{}</span></td>
|
||
<td class=\"actions\">
|
||
<button class=\"btn-icon\" hx-get=\"/api/crm/contacts/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
|
||
<button class=\"btn-icon\" hx-delete=\"/api/crm/contacts/{}\" hx-confirm=\"Delete this contact?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
|
||
</td>
|
||
</tr>",
|
||
contact.id,
|
||
contact.id,
|
||
html_escape(&name),
|
||
html_escape(email),
|
||
html_escape(phone),
|
||
html_escape(company),
|
||
contact.status,
|
||
contact.status,
|
||
contact.id,
|
||
contact.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_accounts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html(render_empty_table("accounts", "🏢", "No accounts yet", "Add company accounts to your CRM"));
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let accounts: Vec<CrmAccount> = crm_accounts::table
|
||
.filter(crm_accounts::org_id.eq(org_id))
|
||
.filter(crm_accounts::bot_id.eq(bot_id))
|
||
.order(crm_accounts::created_at.desc())
|
||
.limit(50)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if accounts.is_empty() {
|
||
return Html(render_empty_table("accounts", "🏢", "No accounts yet", "Add company accounts to your CRM"));
|
||
}
|
||
|
||
let mut html = String::new();
|
||
for account in accounts {
|
||
let website = account.website.as_deref().unwrap_or("-");
|
||
let industry = account.industry.as_deref().unwrap_or("-");
|
||
let employees = account
|
||
.employees_count
|
||
.map(|e| e.to_string())
|
||
.unwrap_or_else(|| "-".to_string());
|
||
let phone = account.phone.as_deref().unwrap_or("-");
|
||
|
||
html.push_str(&format!(
|
||
"<tr class=\"account-row\" data-id=\"{}\">
|
||
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
|
||
<td class=\"account-name\">{}</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td>{}</td>
|
||
<td class=\"actions\">
|
||
<button class=\"btn-icon\" hx-get=\"/api/crm/accounts/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
|
||
<button class=\"btn-icon\" hx-delete=\"/api/crm/accounts/{}\" hx-confirm=\"Delete this account?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
|
||
</td>
|
||
</tr>",
|
||
account.id,
|
||
account.id,
|
||
html_escape(&account.name),
|
||
html_escape(website),
|
||
html_escape(industry),
|
||
employees,
|
||
html_escape(phone),
|
||
account.id,
|
||
account.id
|
||
));
|
||
}
|
||
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_crm_search(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(query): Query<SearchQuery>,
|
||
) -> impl IntoResponse {
|
||
let q = query.q.unwrap_or_default();
|
||
if q.is_empty() {
|
||
return Html(String::new());
|
||
}
|
||
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("<div class=\"search-results-empty\"><p>Search unavailable</p></div>".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
let pattern = format!("%{q}%");
|
||
|
||
let contacts: Vec<CrmContact> = crm_contacts::table
|
||
.filter(crm_contacts::org_id.eq(org_id))
|
||
.filter(crm_contacts::bot_id.eq(bot_id))
|
||
.filter(
|
||
crm_contacts::first_name.ilike(&pattern)
|
||
.or(crm_contacts::last_name.ilike(&pattern))
|
||
.or(crm_contacts::email.ilike(&pattern))
|
||
.or(crm_contacts::company.ilike(&pattern))
|
||
)
|
||
.limit(10)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
let leads: Vec<CrmLead> = crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.filter(crm_leads::title.ilike(&pattern))
|
||
.limit(10)
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if contacts.is_empty() && leads.is_empty() {
|
||
return Html(format!(
|
||
"<div class=\"search-results-empty\"><p>No results for \"{}\"</p></div>",
|
||
html_escape(&q)
|
||
));
|
||
}
|
||
|
||
let mut html = String::from("<div class=\"search-results\">");
|
||
|
||
if !contacts.is_empty() {
|
||
html.push_str("<div class=\"search-section\"><h4>Contacts</h4>");
|
||
for contact in contacts {
|
||
let name = format!(
|
||
"{} {}",
|
||
contact.first_name.as_deref().unwrap_or(""),
|
||
contact.last_name.as_deref().unwrap_or("")
|
||
).trim().to_string();
|
||
let email = contact.email.as_deref().unwrap_or("");
|
||
html.push_str(&format!(
|
||
"<div class=\"search-result-item\" hx-get=\"/api/crm/contacts/{}\" hx-target=\"#detail-panel\">
|
||
<span class=\"result-name\">{}</span>
|
||
<span class=\"result-detail\">{}</span>
|
||
</div>",
|
||
contact.id,
|
||
html_escape(&name),
|
||
html_escape(email)
|
||
));
|
||
}
|
||
html.push_str("</div>");
|
||
}
|
||
|
||
if !leads.is_empty() {
|
||
html.push_str("<div class=\"search-section\"><h4>Leads</h4>");
|
||
for lead in leads {
|
||
let value = lead.value.map(|v| format!("${}", v)).unwrap_or_default();
|
||
html.push_str(&format!(
|
||
"<div class=\"search-result-item\" hx-get=\"/api/crm/leads/{}\" hx-target=\"#detail-panel\">
|
||
<span class=\"result-name\">{}</span>
|
||
<span class=\"result-detail\">{}</span>
|
||
</div>",
|
||
lead.id,
|
||
html_escape(&lead.title),
|
||
value
|
||
));
|
||
}
|
||
html.push_str("</div>");
|
||
}
|
||
|
||
html.push_str("</div>");
|
||
Html(html)
|
||
}
|
||
|
||
async fn handle_conversion_rate(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("0%".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let total_leads: i64 = crm_leads::table
|
||
.filter(crm_leads::org_id.eq(org_id))
|
||
.filter(crm_leads::bot_id.eq(bot_id))
|
||
.count()
|
||
.get_result(&mut conn)
|
||
.unwrap_or(0);
|
||
|
||
let won_opportunities: i64 = crm_opportunities::table
|
||
.filter(crm_opportunities::org_id.eq(org_id))
|
||
.filter(crm_opportunities::bot_id.eq(bot_id))
|
||
.filter(crm_opportunities::won.eq(Some(true)))
|
||
.count()
|
||
.get_result(&mut conn)
|
||
.unwrap_or(0);
|
||
|
||
let rate = if total_leads > 0 {
|
||
(won_opportunities as f64 / total_leads as f64) * 100.0
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
Html(format!("{:.1}%", rate))
|
||
}
|
||
|
||
async fn handle_pipeline_value(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("$0".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let opportunities: Vec<CrmOpportunity> = crm_opportunities::table
|
||
.filter(crm_opportunities::org_id.eq(org_id))
|
||
.filter(crm_opportunities::bot_id.eq(bot_id))
|
||
.filter(crm_opportunities::won.is_null())
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
let total: f64 = opportunities
|
||
.iter()
|
||
.filter_map(|o| o.value.as_ref())
|
||
.filter_map(|v| v.to_string().parse::<f64>().ok())
|
||
.sum();
|
||
|
||
Html(format_currency(total))
|
||
}
|
||
|
||
async fn handle_avg_deal(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("$0".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let won_opportunities: Vec<CrmOpportunity> = crm_opportunities::table
|
||
.filter(crm_opportunities::org_id.eq(org_id))
|
||
.filter(crm_opportunities::bot_id.eq(bot_id))
|
||
.filter(crm_opportunities::won.eq(Some(true)))
|
||
.load(&mut conn)
|
||
.unwrap_or_default();
|
||
|
||
if won_opportunities.is_empty() {
|
||
return Html("$0".to_string());
|
||
}
|
||
|
||
let total: f64 = won_opportunities
|
||
.iter()
|
||
.filter_map(|o| o.value.as_ref())
|
||
.filter_map(|v| v.to_string().parse::<f64>().ok())
|
||
.sum();
|
||
|
||
let avg = total / won_opportunities.len() as f64;
|
||
Html(format_currency(avg))
|
||
}
|
||
|
||
async fn handle_won_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||
let Ok(mut conn) = state.conn.get() else {
|
||
return Html("0".to_string());
|
||
};
|
||
|
||
let (org_id, bot_id) = get_bot_context(&state);
|
||
|
||
let count: i64 = crm_opportunities::table
|
||
.filter(crm_opportunities::org_id.eq(org_id))
|
||
.filter(crm_opportunities::bot_id.eq(bot_id))
|
||
.filter(crm_opportunities::won.eq(Some(true)))
|
||
.count()
|
||
.get_result(&mut conn)
|
||
.unwrap_or(0);
|
||
|
||
Html(count.to_string())
|
||
}
|
||
|
||
fn render_empty_pipeline(stage: &str) -> String {
|
||
format!(
|
||
"<div class=\"pipeline-empty\"><p>No {} items yet</p></div>",
|
||
stage
|
||
)
|
||
}
|
||
|
||
fn render_empty_table(_entity: &str, icon: &str, title: &str, hint: &str) -> String {
|
||
format!(
|
||
"<tr class=\"empty-row\">
|
||
<td colspan=\"7\" class=\"empty-state\">
|
||
<div class=\"empty-icon\">{}</div>
|
||
<p>{}</p>
|
||
<p class=\"empty-hint\">{}</p>
|
||
</td>
|
||
</tr>",
|
||
icon, title, hint
|
||
)
|
||
}
|
||
|
||
fn html_escape(s: &str) -> String {
|
||
s.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
.replace('\'', "'")
|
||
}
|
||
|
||
fn format_currency(value: f64) -> String {
|
||
if value >= 1_000_000.0 {
|
||
format!("${:.1}M", value / 1_000_000.0)
|
||
} else if value >= 1_000.0 {
|
||
format!("${:.1}K", value / 1_000.0)
|
||
} else {
|
||
format!("${:.0}", value)
|
||
}
|
||
}
|