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

@@ -292,6 +292,7 @@ shop-subtitle = browse our products.
shop-empty = There are no products here yet. shop-empty = There are no products here yet.
categories = Categories categories = Categories
all-products = All products all-products = All products
uncategorized = Uncategorized
cart-title = Cart cart-title = Cart
cart-empty = Your cart is empty. cart-empty = Your cart is empty.
cart-total = Total cart-total = Total

View File

@@ -292,6 +292,7 @@ shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty. shop-empty = Zatiaľ tu nie sú žiadne produkty.
categories = Kategórie categories = Kategórie
all-products = Všetky produkty all-products = Všetky produkty
uncategorized = Bez kategórie
cart-title = Košík cart-title = Košík
cart-empty = Váš košík je prázdny. cart-empty = Váš košík je prázdny.
cart-total = Spolu cart-total = Spolu

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,11 @@
{% endif %} {% endif %}
</section> </section>
<div class="mt-4 {{ ui::table_wrap_cls() }}"> {% set category_base = "/admin/catalog/products" %}
{% set category_suffix = "&audience=" ~ audience %}
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %} {% if products | length > 0 %}
<table class="{{ ui::table_cls() }}"> <table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}"> <thead class="{{ ui::thead_cls() }}">
@@ -138,5 +142,6 @@
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }} {{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
</div> </div>
{% endif %} {% endif %}
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -43,7 +43,11 @@
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p> <p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
<div class="mt-3 {{ ui::table_wrap_cls() }}"> {% set category_base = "/admin/customers/" ~ customer.id %}
{% set category_suffix = "" %}
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %} {% if products | length > 0 %}
<table class="{{ ui::table_cls() }}"> <table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}"> <thead class="{{ ui::thead_cls() }}">
@@ -91,5 +95,6 @@
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p> <p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
</div> </div>
{% endif %} {% endif %}
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -0,0 +1,75 @@
{# Category-filter sidebar for admin product listings. Clicking a category
reloads the page with `?category=<id>` so the table server-side filters to
that category and its descendants. Expects in context:
- category_groups: [{ id, name, count, children: [{ id, name, count }] }]
(from views::shop::admin_category_groups)
- selected_category: "all" | "none" | "<id>" — the active filter
- total_count, uncategorized_count: ints
- category_base: page path, e.g. "/admin/catalog/products"
- category_suffix: extra query appended after the category param, e.g.
"&audience=business", or "" — set by the including template.
The link treatment mirrors shop/_sidebar.html (Penguin UI), but active state
is server-driven via aria-current (these links share a path, differing only
by query, so markActiveNav() can't pick the active one — hence no data-nav).
Numeric compare uses `| int(default=0)` because Tera string==number is false. #}
{% set sel = selected_category | int(default=0) %}
{% set link_cls = "flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong" %}
<aside class="w-full shrink-0 md:w-56">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="categories", lang=lang | default(value='sk')) }}
</p>
<div class="flex flex-col gap-1">
<a href="{{ category_base }}?category=all{{ category_suffix }}"
{% if selected_category == "all" %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ t(key="all-products", lang=lang | default(value='sk')) }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ total_count }}</span>
</a>
{% for group in category_groups %}
{% set open_group = sel == group.id %}
{% for child in group.children %}{% if sel == child.id %}{% set_global open_group = true %}{% endif %}{% endfor %}
{% if group.children | length > 0 %}
<div x-data="{ open: {% if open_group %}true{% else %}false{% endif %} }" class="flex flex-col">
<div class="flex items-stretch">
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }} rounded-l-radius">
<span class="flex-1 truncate">{{ group.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
</a>
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
aria-label="{{ group.name }}"
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
{% for child in group.children %}
<li class="flex">
<a href="{{ category_base }}?category={{ child.id }}{{ category_suffix }}"
{% if sel == child.id %}aria-current="page"{% endif %}
class="flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
<span class="flex-1 truncate">{{ child.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ child.count }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ group.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
</a>
{% endif %}
{% endfor %}
<a href="{{ category_base }}?category=none{{ category_suffix }}"
{% if selected_category == "none" %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ t(key="uncategorized", lang=lang | default(value='sk')) }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ uncategorized_count }}</span>
</a>
</div>
</aside>

View File

@@ -21,13 +21,14 @@ use crate::{
controllers::i18n::current_lang, controllers::i18n::current_lang,
models::{ models::{
account_discount_profiles, account_product_prices, account_product_resolutions, account_discount_profiles, account_product_prices, account_product_resolutions,
discount_profiles, products, _entities::users, categories, discount_profiles, products, _entities::users,
}, },
shared::{ shared::{
guard, guard,
money::{format_bp, format_price, parse_price_to_cents}, money::{format_bp, format_price, parse_price_to_cents},
pricing, pricing,
}, },
views::shop as view,
}; };
const COMPANY: &str = "company"; const COMPANY: &str = "company";
@@ -131,6 +132,12 @@ async fn show(
}) })
.collect(); .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() let list = products::Entity::find()
.order_by_asc(products::Column::Name) .order_by_asc(products::Column::Name)
.all(&ctx.db) .all(&ctx.db)
@@ -143,10 +150,22 @@ async fn show(
let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?; let business = pricing::audience_price_many(&ctx, &list, BUSINESS_AUDIENCE).await?;
let details = pricing::detail_many(&ctx, &list, Some(&company)).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 let rows: Vec<serde_json::Value> = list
.iter() .iter()
.zip(business.iter()) .zip(business.iter())
.zip(details.iter()) .zip(details.iter())
.filter(|((product, _), _)| view::category_filter_keep(&filter, product.category_id))
.map(|((product, b), d)| { .map(|((product, b), d)| {
json!({ json!({
"product_id": product.id, "product_id": product.id,
@@ -170,6 +189,10 @@ async fn show(
"customer": { "id": company.id, "name": company.name, "email": company.email }, "customer": { "id": company.id, "name": company.name, "email": company.email },
"profiles": profiles_json, "profiles": profiles_json,
"products": rows, "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"), "error": params.get("error"),
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),

View File

@@ -126,6 +126,16 @@ async fn index(
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params); 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() let list = products::Entity::find()
.order_by_desc(products::Column::CreatedAt) .order_by_desc(products::Column::CreatedAt)
.all(&ctx.db) .all(&ctx.db)
@@ -133,16 +143,27 @@ async fn index(
// Effective price each product carries for the active audience, after the // Effective price each product carries for the active audience, after the
// global per-product discount and any profiles assigned to that audience. // global per-product discount and any profiles assigned to that audience.
let effective = pricing::audience_price_many(&ctx, &list, audience).await?; 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(); let mut rows = Vec::new();
for (product, priced) in list.iter().zip(effective.iter()) { 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 image = product_images::first_for(&ctx, product.id).await?;
let category_name = match product.category_id { let category_name = product
Some(id) => categories::Entity::find_by_id(id) .category_id
.one(&ctx.db) .and_then(|id| category_name.get(&id).cloned());
.await?
.map(|c| c.name),
None => None,
};
rows.push(product_row(product, priced, image, category_name, audience)); rows.push(product_row(product, priced, image, category_name, audience));
} }
@@ -153,6 +174,10 @@ async fn index(
"products": rows, "products": rows,
"profiles": load_audience_profiles(&ctx, audience).await?, "profiles": load_audience_profiles(&ctx, audience).await?,
"audience": audience, "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), "lang": current_lang(&jar),
}), }),
) )

View File

@@ -74,3 +74,85 @@ pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
}) })
.collect() .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)),
}
}