feat(api): add CRM, billing, products stub UI routes

- Add crm_ui.rs with stub handlers for pipeline, leads, contacts, accounts, stats
- Add billing_ui.rs with stub handlers for invoices, payments, quotes, stats
- Add products module with stub handlers for items, services, pricelists, stats
- Register routes in main.rs

These stubs return empty data/HTML to prevent 404 errors in UI.
Full CRUD implementation to follow.
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-12 14:35:03 -03:00
parent 4ed05f3f19
commit 67c9b0e0cc
7 changed files with 389 additions and 0 deletions

112
src/billing/billing_ui.rs Normal file
View file

@ -0,0 +1,112 @@
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Router,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::shared::state::AppState;
#[derive(Debug, Deserialize)]
pub struct StatusQuery {
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
pub fn configure_billing_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/billing/invoices", get(handle_invoices))
.route("/api/billing/payments", get(handle_payments))
.route("/api/billing/quotes", get(handle_quotes))
.route("/api/billing/stats/pending", get(handle_stats_pending))
.route("/api/billing/stats/revenue-month", get(handle_revenue_month))
.route("/api/billing/stats/paid-month", get(handle_paid_month))
.route("/api/billing/stats/overdue", get(handle_overdue))
.route("/api/billing/search", get(handle_billing_search))
}
async fn handle_invoices(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">📄</div>
<p>No invoices yet</p>
<p class="empty-hint">Create your first invoice to get started</p>
</td>
</tr>"#
.to_string(),
)
}
async fn handle_payments(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">💳</div>
<p>No payments recorded</p>
<p class="empty-hint">Payments will appear here when invoices are paid</p>
</td>
</tr>"#
.to_string(),
)
}
async fn handle_quotes(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">📝</div>
<p>No quotes yet</p>
<p class="empty-hint">Create quotes for your prospects</p>
</td>
</tr>"#
.to_string(),
)
}
async fn handle_stats_pending(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_revenue_month(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_paid_month(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_overdue(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_billing_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());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
))
}

View file

@ -6,6 +6,7 @@ use tokio::sync::RwLock;
use uuid::Uuid; use uuid::Uuid;
pub mod alerts; pub mod alerts;
pub mod billing_ui;
pub mod invoice; pub mod invoice;
pub mod lifecycle; pub mod lifecycle;
pub mod meters; pub mod meters;

154
src/contacts/crm_ui.rs Normal file
View file

@ -0,0 +1,154 @@
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::shared::state::AppState;
#[derive(Debug, Deserialize)]
pub struct StageQuery {
pub stage: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CountResponse {
pub count: i64,
}
#[derive(Debug, Serialize)]
pub struct StatsResponse {
pub value: String,
}
pub fn configure_crm_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/crm/count", get(handle_crm_count))
.route("/api/crm/pipeline", get(handle_crm_pipeline))
.route("/api/crm/leads", get(handle_crm_leads))
.route("/api/crm/opportunities", get(handle_crm_opportunities))
.route("/api/crm/contacts", get(handle_crm_contacts))
.route("/api/crm/accounts", get(handle_crm_accounts))
.route("/api/crm/search", get(handle_crm_search))
.route("/api/crm/stats/conversion-rate", get(handle_conversion_rate))
.route("/api/crm/stats/pipeline-value", get(handle_pipeline_value))
.route("/api/crm/stats/avg-deal", get(handle_avg_deal))
.route("/api/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 _stage = query.stage.unwrap_or_else(|| "all".to_string());
Html("0".to_string())
}
async fn handle_crm_pipeline(
State(_state): State<Arc<AppState>>,
Query(query): Query<StageQuery>,
) -> impl IntoResponse {
let stage = query.stage.unwrap_or_else(|| "lead".to_string());
Html(format!(
r#"<div class="pipeline-empty">
<p>No {} items yet</p>
</div>"#,
stage
))
}
async fn handle_crm_leads(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">📋</div>
<p>No leads yet</p>
<p class="empty-hint">Create your first lead to get started</p>
</td>
</tr>"#.to_string())
}
async fn handle_crm_opportunities(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">💼</div>
<p>No opportunities yet</p>
<p class="empty-hint">Qualify leads to create opportunities</p>
</td>
</tr>"#.to_string())
}
async fn handle_crm_contacts(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">👥</div>
<p>No contacts yet</p>
<p class="empty-hint">Add contacts to your CRM</p>
</td>
</tr>"#.to_string())
}
async fn handle_crm_accounts(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">🏢</div>
<p>No accounts yet</p>
<p class="empty-hint">Add company accounts to your CRM</p>
</td>
</tr>"#.to_string())
}
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());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
))
}
async fn handle_conversion_rate(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("0%".to_string())
}
async fn handle_pipeline_value(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_avg_deal(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("$0".to_string())
}
async fn handle_won_month(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("0".to_string())
}

View file

@ -1,4 +1,5 @@
pub mod calendar_integration; pub mod calendar_integration;
pub mod crm_ui;
pub mod external_sync; pub mod external_sync;
pub mod tasks_integration; pub mod tasks_integration;

View file

@ -10,6 +10,7 @@ pub mod embedded_ui;
pub mod maintenance; pub mod maintenance;
pub mod multimodal; pub mod multimodal;
pub mod player; pub mod player;
pub mod products;
pub mod search; pub mod search;
pub mod security; pub mod security;

View file

@ -384,6 +384,9 @@ async fn run_axum_server(
api_router = api_router.merge(botserver::player::configure_player_routes()); api_router = api_router.merge(botserver::player::configure_player_routes());
api_router = api_router.merge(botserver::canvas::configure_canvas_routes()); api_router = api_router.merge(botserver::canvas::configure_canvas_routes());
api_router = api_router.merge(botserver::social::configure_social_routes()); api_router = api_router.merge(botserver::social::configure_social_routes());
api_router = api_router.merge(botserver::contacts::crm_ui::configure_crm_routes());
api_router = api_router.merge(botserver::billing::billing_ui::configure_billing_routes());
api_router = api_router.merge(botserver::products::configure_products_routes());
#[cfg(feature = "whatsapp")] #[cfg(feature = "whatsapp")]
{ {

117
src/products/mod.rs Normal file
View file

@ -0,0 +1,117 @@
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Router,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::shared::state::AppState;
#[derive(Debug, Deserialize)]
pub struct ProductQuery {
pub category: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
pub fn configure_products_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/products/items", get(handle_products_items))
.route("/api/products/services", get(handle_products_services))
.route("/api/products/pricelists", get(handle_products_pricelists))
.route(
"/api/products/stats/total-products",
get(handle_total_products),
)
.route(
"/api/products/stats/total-services",
get(handle_total_services),
)
.route("/api/products/stats/pricelists", get(handle_total_pricelists))
.route("/api/products/stats/active", get(handle_active_products))
.route("/api/products/search", get(handle_products_search))
}
async fn handle_products_items(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<div class="products-empty">
<div class="empty-icon">📦</div>
<p>No products yet</p>
<p class="empty-hint">Add your first product to get started</p>
</div>"#
.to_string(),
)
}
async fn handle_products_services(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">🔧</div>
<p>No services yet</p>
<p class="empty-hint">Add services to your catalog</p>
</td>
</tr>"#
.to_string(),
)
}
async fn handle_products_pricelists(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="5" class="empty-state">
<div class="empty-icon">💰</div>
<p>No price lists yet</p>
<p class="empty-hint">Create price lists for different customer segments</p>
</td>
</tr>"#
.to_string(),
)
}
async fn handle_total_products(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
}
async fn handle_total_services(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
}
async fn handle_total_pricelists(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
}
async fn handle_active_products(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
}
async fn handle_products_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());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
))
}