tinkr 0.0.43

Tinkr is a web framework for quickly building full-stack web applications with Leptos.
Documentation
use leptos::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Clone, Debug)]
struct OrdersInProgress {
    count: usize,
}

#[derive(Deserialize, Serialize, Clone, Debug)]
struct WeeklyComparison {
    last_7_days_total: f64,
    previous_7_days_total: f64,
    percentage_change: f64,
}

#[derive(Deserialize, Serialize, Clone, Debug)]
struct MonthlyComparison {
    last_month_name: String,
    last_month_total: f64,
    previous_month_total: f64,
    percentage_change: f64,
}

#[server]
async fn fetch_in_progress_orders() -> Result<OrdersInProgress, ServerFnError> {
    let user = crate::session::get_user().await?;
    if user.is_admin != Some(true) {
        return Err(ServerFnError::ServerError("Unauthorized".into()));
    }

    let db = crate::db::db_init().await?;

    let mut result = db
        .query("SELECT count() as count FROM order WHERE paid = true and done != true GROUP ALL;")
        .await?;

    let count: Option<usize> = result.take("count")?;

    Ok(OrdersInProgress {
        count: count.unwrap_or(0),
    })
}

#[server]
async fn fetch_weekly_comparison() -> Result<WeeklyComparison, ServerFnError> {
    let user = crate::session::get_user().await?;
    if user.is_admin != Some(true) {
        return Err(ServerFnError::ServerError("Unauthorized".into()));
    }

    let db = crate::db::db_init().await?;

    // Get orders from the last 14 days
    let mut orders_req = db
        .query(
            "SELECT created_at, total FROM order WHERE paid = true AND created_at > time::now() - 14d ORDER BY created_at ASC;",
        )
        .await?;

    #[derive(Deserialize, Serialize, Clone, Debug)]
    struct OrderData {
        created_at: crate::surrealtypes::Datetime,
        total: String,
    }

    let orders: Vec<OrderData> = orders_req.take(0)?;

    let now = chrono::Utc::now();
    let seven_days_ago = now - chrono::Duration::days(7);

    let mut last_7_days_total = 0.0;
    let mut previous_7_days_total = 0.0;

    for order in orders {
        let order_date = chrono::DateTime::parse_from_rfc3339(&order.created_at.to_string())
            .map_err(|e| ServerFnError::new(format!("Date parse error: {}", e)))?
            .with_timezone(&chrono::Utc);

        let total: f64 = order.total.parse().unwrap_or(0.0);

        if order_date >= seven_days_ago {
            last_7_days_total += total;
        } else {
            previous_7_days_total += total;
        }
    }

    let percentage_change = if previous_7_days_total > 0.0 {
        ((last_7_days_total - previous_7_days_total) / previous_7_days_total) * 100.0
    } else if last_7_days_total > 0.0 {
        100.0
    } else {
        0.0
    };

    Ok(WeeklyComparison {
        last_7_days_total,
        previous_7_days_total,
        percentage_change,
    })
}

#[server]
async fn fetch_monthly_comparison() -> Result<MonthlyComparison, ServerFnError> {
    let user = crate::session::get_user().await?;
    if user.is_admin != Some(true) {
        return Err(ServerFnError::ServerError("Unauthorized".into()));
    }

    let db = crate::db::db_init().await?;

    // Get all paid orders
    let mut orders_req = db
        .query("SELECT created_at, total FROM order WHERE paid = true ORDER BY created_at ASC;")
        .await?;

    #[derive(Deserialize, Serialize, Clone, Debug)]
    struct OrderData {
        created_at: crate::surrealtypes::Datetime,
        total: String,
    }

    let orders: Vec<OrderData> = orders_req.take(0)?;

    // Group by month
    let mut monthly_totals: std::collections::HashMap<String, f64> =
        std::collections::HashMap::new();

    for order in orders {
        let month = order.created_at.format("%Y-%m");
        let total: f64 = order.total.parse().unwrap_or(0.0);

        *monthly_totals.entry(month).or_insert(0.0) += total;
    }

    // Sort months
    let mut months: Vec<(String, f64)> = monthly_totals.into_iter().collect();
    months.sort_by(|a, b| a.0.cmp(&b.0));

    // Get the last two complete months (not current month)
    let now = chrono::Utc::now();
    let current_month = now.format("%Y-%m").to_string();

    // Filter out current month and get last 2
    let complete_months: Vec<(String, f64)> = months
        .into_iter()
        .filter(|(month, _)| month != &current_month)
        .collect();

    let len = complete_months.len();

    if len < 2 {
        return Ok(MonthlyComparison {
            last_month_name: if len == 1 {
                format_month_name(&complete_months[0].0)
            } else {
                "N/A".to_string()
            },
            last_month_total: if len == 1 { complete_months[0].1 } else { 0.0 },
            previous_month_total: 0.0,
            percentage_change: 0.0,
        });
    }

    let last_month = &complete_months[len - 1];
    let previous_month = &complete_months[len - 2];

    let percentage_change = if previous_month.1 > 0.0 {
        ((last_month.1 - previous_month.1) / previous_month.1) * 100.0
    } else if last_month.1 > 0.0 {
        100.0
    } else {
        0.0
    };

    Ok(MonthlyComparison {
        last_month_name: format_month_name(&last_month.0),
        last_month_total: last_month.1,
        previous_month_total: previous_month.1,
        percentage_change,
    })
}

fn format_month_name(month_str: &str) -> String {
    if let Some(parts) = month_str.split('-').collect::<Vec<_>>().get(0..2) {
        let year = parts[0];
        let month_num = parts[1];
        let month_name = match month_num {
            "01" => "Jan",
            "02" => "Feb",
            "03" => "Mar",
            "04" => "Apr",
            "05" => "May",
            "06" => "Jun",
            "07" => "Jul",
            "08" => "Aug",
            "09" => "Sep",
            "10" => "Oct",
            "11" => "Nov",
            "12" => "Dec",
            _ => month_num,
        };
        format!("{} {}", month_name, year)
    } else {
        month_str.to_string()
    }
}

#[component]
fn WidgetCard(title: String, children: Children) -> impl IntoView {
    view! {
        <div class="bg-white dark:bg-neutral-900 p-2 md:p-6 md:rounded-lg shadow">
            <div class="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-2">
                {title}
            </div>
            {children()}
        </div>
    }
}

#[component]
pub fn MetricsWidgets() -> impl IntoView {
    let in_progress_resource =
        Resource::new(|| (), |_| async move { fetch_in_progress_orders().await });

    let weekly_resource = Resource::new(|| (), |_| async move { fetch_weekly_comparison().await });

    let monthly_resource =
        Resource::new(|| (), |_| async move { fetch_monthly_comparison().await });

    view! {
        <div class="grid grid-cols-3 md:grid-cols-3 gap-1">
            // Orders in Progress
            <WidgetCard title="Orders".to_string()>
                <Suspense fallback=move || {
                    view! { <div class="text-2xl font-bold">"..."</div> }
                }>
                    {move || {
                        in_progress_resource
                            .get()
                            .map(|result| match result {
                                Ok(data) => {
                                    view! {
                                        <div class="md:text-3xl font-bold text-neutral-900 dark:text-neutral-100">
                                            {data.count.to_string()}
                                        </div>
                                    }
                                        .into_any()
                                }
                                Err(err) => {
                                    view! {
                                        <div class="text-sm text-red-500">{err.to_string()}</div>
                                    }
                                        .into_any()
                                }
                            })
                    }}

                </Suspense>
            </WidgetCard>

            // 7-Day Comparison
            <WidgetCard title="Last 7 Days".to_string()>
                <Suspense fallback=move || {
                    view! { <div class="text-2xl font-bold">"..."</div> }
                }>
                    {move || {
                        weekly_resource
                            .get()
                            .map(|result| match result {
                                Ok(data) => {
                                    let is_positive = data.percentage_change >= 0.0;
                                    let color_class = if is_positive {
                                        "text-green-600 dark:text-green-400"
                                    } else {
                                        "text-red-600 dark:text-red-400"
                                    };
                                    let arrow = if is_positive { "\u{2191}" } else { "\u{2193}" };
                                    view! {
                                        <div class="flex items-baseline gap-2">
                                            <div class="md:text-3xl font-bold text-neutral-900 dark:text-neutral-100">
                                                "R " {data.last_7_days_total.round().to_string()}
                                            </div>
                                        </div>
                                        <div class=format!(
                                            "text-sm font-semibold mt-1 {}",
                                            color_class,
                                        )>
                                            {arrow} " "
                                            {format!("{:.1}%", data.percentage_change.abs())}
                                        </div>
                                    }
                                        .into_any()
                                }
                                Err(err) => {
                                    view! {
                                        <div class="text-sm text-red-500">{err.to_string()}</div>
                                    }
                                        .into_any()
                                }
                            })
                    }}

                </Suspense>
            </WidgetCard>

            // Monthly Comparison
            <Suspense fallback=move || {
                view! {
                    <WidgetCard title="Loading...".to_string()>
                        <div class="text-2xl font-bold">"..."</div>
                    </WidgetCard>
                }
            }>
                {move || {
                    monthly_resource
                        .get()
                        .map(|result| match result {
                            Ok(data) => {
                                let is_positive = data.percentage_change >= 0.0;
                                let color_class = if is_positive {
                                    "text-green-600 dark:text-green-400"
                                } else {
                                    "text-red-600 dark:text-red-400"
                                };
                                let arrow = if is_positive { "\u{2191}" } else { "\u{2193}" };
                                view! {
                                    <WidgetCard title=data.last_month_name.clone()>
                                        <div class="flex items-baseline gap-2">
                                            <div class="md:text-3xl font-bold text-neutral-900 dark:text-neutral-100">
                                                "R " {data.last_month_total.round().to_string()}
                                            </div>
                                        </div>
                                        <div class=format!(
                                            "text-sm font-semibold mt-1 {}",
                                            color_class,
                                        )>
                                            {arrow} " "
                                            {format!("{:.1}%", data.percentage_change.abs())}
                                        </div>
                                    </WidgetCard>
                                }
                                    .into_any()
                            }
                            Err(err) => {
                                view! {
                                    <WidgetCard title="Error".to_string()>
                                        <div class="text-sm text-red-500">{err.to_string()}</div>
                                    </WidgetCard>
                                }
                                    .into_any()
                            }
                        })
                }}

            </Suspense>
        </div>
    }
}