botserver/src/contacts/crm_ui.rs
Rodrigo Rodriguez (Pragmatismo) ef426b7a50
Some checks failed
BotServer CI / build (push) Failing after 7m5s
LXD proxy and container improvements
2026-03-15 15:50:02 -03:00

751 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
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)
}
}