sidebar in the admin

This commit is contained in:
Priec
2026-06-22 12:49:08 +02:00
parent 09634e1cd8
commit 77d5c0fc25
9 changed files with 228 additions and 11 deletions

View File

@@ -21,13 +21,14 @@ use crate::{
controllers::i18n::current_lang,
models::{
account_discount_profiles, account_product_prices, account_product_resolutions,
discount_profiles, products, _entities::users,
categories, discount_profiles, products, _entities::users,
},
shared::{
guard,
money::{format_bp, format_price, parse_price_to_cents},
pricing,
},
views::shop as view,
};
const COMPANY: &str = "company";
@@ -131,6 +132,12 @@ async fn show(
})
.collect();
let all_categories = categories::Entity::find()
.order_by_asc(categories::Column::Position)
.order_by_asc(categories::Column::Name)
.all(&ctx.db)
.await?;
let list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
@@ -143,10 +150,22 @@ async fn show(
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
// Category sidebar tree (counts over the full, unfiltered list) plus the
// active `?category=` filter applied to the rows.
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
let selected_category = params
.get("category")
.map(String::as_str)
.unwrap_or("all")
.to_string();
let filter = view::category_filter_ids(&all_categories, &selected_category);
let rows: Vec<serde_json::Value> = list
.iter()
.zip(business.iter())
.zip(details.iter())
.filter(|((product, _), _)| view::category_filter_keep(&filter, product.category_id))
.map(|((product, b), d)| {
json!({
"product_id": product.id,
@@ -170,6 +189,10 @@ async fn show(
"customer": { "id": company.id, "name": company.name, "email": company.email },
"profiles": profiles_json,
"products": rows,
"category_groups": category_groups,
"selected_category": selected_category,
"total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"error": params.get("error"),
"lang": current_lang(&jar),
}),

View File

@@ -126,6 +126,16 @@ async fn index(
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let all_categories = categories::Entity::find()
.order_by_asc(categories::Column::Position)
.order_by_asc(categories::Column::Name)
.all(&ctx.db)
.await?;
let category_name: HashMap<i32, String> = all_categories
.iter()
.map(|c| (c.id, c.name.clone()))
.collect();
let list = products::Entity::find()
.order_by_desc(products::Column::CreatedAt)
.all(&ctx.db)
@@ -133,16 +143,27 @@ async fn index(
// Effective price each product carries for the active audience, after the
// global per-product discount and any profiles assigned to that audience.
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
// Category sidebar tree (counts over the full, unfiltered list) plus the
// active `?category=` filter applied to the rows.
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
let selected_category = params
.get("category")
.map(String::as_str)
.unwrap_or("all")
.to_string();
let filter = view::category_filter_ids(&all_categories, &selected_category);
let mut rows = Vec::new();
for (product, priced) in list.iter().zip(effective.iter()) {
if !view::category_filter_keep(&filter, product.category_id) {
continue;
}
let image = product_images::first_for(&ctx, product.id).await?;
let category_name = match product.category_id {
Some(id) => categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.map(|c| c.name),
None => None,
};
let category_name = product
.category_id
.and_then(|id| category_name.get(&id).cloned());
rows.push(product_row(product, priced, image, category_name, audience));
}
@@ -153,6 +174,10 @@ async fn index(
"products": rows,
"profiles": load_audience_profiles(&ctx, audience).await?,
"audience": audience,
"category_groups": category_groups,
"selected_category": selected_category,
"total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"lang": current_lang(&jar),
}),
)

View File

@@ -74,3 +74,85 @@ pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
})
.collect()
}
/// Admin category-filter tree for product listings: like [`sidebar_groups`] but
/// each node carries its `id` (links filter by `?category=<id>`) and a `count`
/// of matching products — the node's own products plus every descendant's, so a
/// parent's count covers its whole subtree. `category_ids` is each product's
/// `category_id` (`None` = uncategorized), taken over the full unfiltered list.
pub fn admin_category_groups(
categories: &[categories::Model],
category_ids: &[Option<i32>],
) -> Vec<Value> {
use std::collections::HashMap;
let mut direct: HashMap<i32, usize> = HashMap::new();
for id in category_ids.iter().flatten() {
*direct.entry(*id).or_default() += 1;
}
let subtree_count = |id: i32| -> usize {
let mut n = direct.get(&id).copied().unwrap_or(0);
for d in crate::models::categories::descendant_ids(categories, id) {
n += direct.get(&d).copied().unwrap_or(0);
}
n
};
let mut top: Vec<&categories::Model> = categories
.iter()
.filter(|c| c.parent_id.is_none())
.collect();
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
top.into_iter()
.map(|category| {
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
.into_iter()
.map(|child| {
json!({ "id": child.id, "name": child.name, "count": subtree_count(child.id) })
})
.collect();
json!({
"id": category.id,
"name": category.name,
"count": subtree_count(category.id),
"children": children,
})
})
.collect()
}
/// Resolve the `?category=` filter param against the category forest into the
/// set of `category_id`s a product may have to be shown. Returns:
/// - `None` for "all" (no filtering) or an unknown value,
/// - `Some(empty set)` for "none" (uncategorized — match products with no
/// category; callers treat an empty set as "uncategorized only"),
/// - `Some({id} descendants)` for a numeric category id.
pub fn category_filter_ids(
categories: &[categories::Model],
selected: &str,
) -> Option<std::collections::HashSet<i32>> {
match selected {
"all" => None,
"none" => Some(std::collections::HashSet::new()),
s => s.parse::<i32>().ok().map(|id| {
let mut set = crate::models::categories::descendant_ids(categories, id);
set.insert(id);
set
}),
}
}
/// Whether a product with `category_id` passes the filter from
/// [`category_filter_ids`]: `None` keeps everything, an empty set keeps only
/// uncategorized products, a non-empty set keeps products in those categories.
pub fn category_filter_keep(
filter: &Option<std::collections::HashSet<i32>>,
category_id: Option<i32>,
) -> bool {
match filter {
None => true,
Some(set) if set.is_empty() => category_id.is_none(),
Some(set) => category_id.is_some_and(|id| set.contains(&id)),
}
}