tinkr 0.0.43

Tinkr is a web framework for quickly building full-stack web applications with Leptos.
Documentation
use crate::components::Page;
use crate::date_utils::FormatDatetime;
use crate::user::DeliveryDetails;
use crate::{Datetime, RecordId};
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map};
use serde::Serialize;
use tw_merge::tw_merge;

#[derive(serde::Deserialize, Serialize, Clone, Debug)]
pub struct DashboardOrderListItem {
    pub id: RecordId,
    pub created_at: Datetime,
    pub user_id: Option<RecordId>,
    pub user_name: Option<String>,
    pub user_email: Option<String>,
    pub total: String,
    pub delivery_details: Option<DeliveryDetails>,
    pub paid: bool,
    pub done: bool,
}

impl DashboardOrderListItem {
    pub fn status_badge(&self) -> impl IntoView {
        let (status_text, status_color) = match (self.paid, self.done) {
            (false, false) => ("UNPAID", "text-orange-400"),
            (true, false) => ("PROCESSING", "text-green-400"),
            (true, true) => ("SENT", "text-blue-400"),
            (false, true) => ("ERROR", "text-red-400"),
        };

        view! {
            <span class=tw_merge!(
                "text-xs leading-5 font-semibold rounded-full", status_color
            )>{status_text}</span>
        }
    }
}

#[server]
async fn fetch_orders() -> Result<Vec<DashboardOrderListItem>, 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 orders_req = db
        .query(
            r#"
            SELECT 
                id, 
                created_at, 
                user.id as user_id, 
                user.name as user_name, 
                user.email as user_email, 
                paid, 
                done, 
                // delivery_details,
                total 
            FROM order 
            WHERE paid = true
            ORDER BY created_at DESC 
            LIMIT 100;"#,
        )
        .await?;

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

    Ok(orders)
}

// Reusable OrdersTable component that can be used by both admin and user views
#[component]
pub fn OrdersTable(
    orders: Vec<DashboardOrderListItem>,
    #[prop(default = true)] show_user_columns: bool,
    #[prop(default = "/admin/{}/edit".to_string())] nav_pattern: String,
) -> impl IntoView {
    let params = use_params_map();
    let navigate = use_navigate();

    view! {
        <div class="">
            <div class="flex flex-row gap-5 items-center px-4 py-2">
                <div class="text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider w-[100px]">
                    "Date"
                </div>

                {show_user_columns
                    .then(|| {
                        view! {
                            <div class="text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider flex-1 hidden">
                                "Client"
                            </div>
                        }
                    })}

                <div class="text-right text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
                    "ORDER"
                </div>
            </div>
            <div class="bg-white dark:bg-neutral-900 divide-y divide-neutral-200 dark:divide-neutral-700 overflow-y-auto max-h-[900px]">
                {move || {
                    if orders.is_empty() {
                        view! {
                            <div class="px-6 py-4 text-center text-neutral-500 dark:text-neutral-400">
                                "No orders found"
                            </div>
                        }
                            .into_any()
                    } else {
                        let current_order_id_value = params
                            .read()
                            .get("order_id")
                            .unwrap_or_default();
                        let current_viewed_order: Option<RecordId> = if current_order_id_value
                            .is_empty()
                        {
                            None
                        } else {
                            Some(RecordId::from_table_key("order", &current_order_id_value))
                        };
                        orders
                            .iter()
                            .map(|order| {
                                let order_ref = order.id.key().to_string();
                                let navigate = navigate.clone();
                                let nav_pattern = nav_pattern.clone();
                                let active = current_viewed_order
                                    .as_ref()
                                    .map_or(false, |oid| oid == &order.id);

                                view! {
                                    <a
                                        class=tw_merge!(
                                            "hover:bg-neutral-50 dark:hover:bg-neutral-700/50 cursor-pointer flex flex-row px-4 py-2 gap-5 items-center",
                                                   if active {
                                                       "bg-neutral-100 dark:bg-neutral-800 font-semibold"
                                                   } else {
                                                       ""
                                                   }
                                        )
                                        on:click=move |_| {
                                            let nav_url = nav_pattern.replace("{}", &order_ref);
                                            navigate(&nav_url, Default::default());
                                        }
                                    >

                                        <div class="whitespace-nowrap text-sm text-neutral-500 dark:text-neutral-400 font-mono tracking-0 text-xs w-[100px]">
                                            {order.created_at.format_custom("%a %d %b")}<br />
                                            <span class="text-neutral-800 dark:text-neutral-200 text-xs">
                                                {order.created_at.ago()}
                                            </span>
                                        </div>

                                        {show_user_columns
                                            .then(|| {
                                                view! {
                                                    <div class="whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100 text-left flex-1">

                                                        {order
                                                            .delivery_details
                                                            .as_ref()
                                                            .and_then(|dd| dd.first_name.clone())
                                                            .unwrap_or_else(|| "Unknown".to_string())} " "
                                                    // {order
                                                    // .user_name
                                                    // .clone()
                                                    // .unwrap_or_else(|| "Unknown".to_string())} <br />
                                                    // <span class="text-neutral-800 dark:text-neutral-200 text-xs">
                                                    // {order
                                                    // .user_email
                                                    // .clone()
                                                    // .unwrap_or_else(|| "N/A".to_string())}
                                                    // </span>
                                                    </div>
                                                }
                                            })}

                                        <div class="whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100 text-right">
                                            "R " {order.total.clone()}<br /> {order.status_badge()}
                                        </div>
                                    </a>
                                }
                            })
                            .collect::<Vec<_>>()
                            .into_any()
                    }
                }}
            </div>

        </div>
    }
}

#[component]
pub fn AdminOrdersTable() -> impl IntoView {
    let orders_resource = OnceResource::new(fetch_orders());

    view! {
        <Page class="flex flex-col p-1">
            <Suspense fallback=move || {
                view! {
                    <div class="px-6 py-4 text-center text-neutral-500 dark:text-neutral-400">
                        "Loading orders..."
                    </div>
                }
            }>

                {move || {
                    orders_resource
                        .get()
                        .map(|orders_result| {
                            match orders_result {
                                Ok(orders) => {
                                    view! {
                                        <OrdersTable
                                            orders=orders
                                            show_user_columns=true
                                            nav_pattern="/admin/order/{}/edit".to_string()
                                        />
                                    }
                                        .into_any()
                                }
                                Err(err) => {
                                    view! {
                                        <div class="px-6 py-4 text-center text-red-500 dark:text-red-400">
                                            "Error loading orders: " {err.to_string()}
                                        </div>
                                    }
                                        .into_any()
                                }
                            }
                        })
                }}

            </Suspense>
        </Page>
    }
}

#[component]
pub fn AdminOrders() -> impl IntoView {
    view! {
        <div class="min-h-screen w-full flex flex-col bg-white dark:bg-neutral-950">
            <div class="container mx-auto px-4 py-8">
                <h1 class="text-3xl font-bold text-neutral-800 dark:text-neutral-200 mb-8">
                    "All Orders"
                </h1>

                <div class="bg-white dark:bg-neutral-900 rounded-lg shadow-md p-6">
                    <h2 class="text-2xl font-semibold text-neutral-800 dark:text-neutral-200 mb-4">
                        "ALL ORDERS"
                    </h2>

                    <AdminOrdersTable />
                </div>
            </div>
        </div>
    }
}