orders search query also working now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-22 21:52:22 +02:00
parent 5a474f3474
commit 5e6263e853
7 changed files with 133 additions and 7 deletions

View File

@@ -308,6 +308,7 @@ shop-title = Shop
shop-subtitle = browse our products.
shop-empty = There are no products here yet.
search-placeholder = Search products…
order-search-placeholder = Search orders…
search-empty = Nothing matched your search:
results-count = { $count } products
sort-label = Sort

View File

@@ -308,6 +308,7 @@ shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty.
search-placeholder = Hľadať produkty…
order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov
sort-label = Zoradiť

View File

@@ -5,7 +5,24 @@
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
{% set L = lang | default(value='sk') %}
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=L) }}</h1>
<!-- order search: order number, customer, email, company, phone, tracking -->
<form method="get" action="/admin/orders" role="search" class="relative w-full max-w-xs">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='order-search-placeholder', lang=L) }}" aria-label="{{ t(key='order-search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
</form>
</div>
{% if query and query != "" %}
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="results-count", lang=L, count=total) }} · “{{ query }}”</p>
{% endif %}
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if orders | length > 0 %}

View File

@@ -44,6 +44,7 @@ mod m20260622_000002_product_variants;
mod m20260622_000003_variant_stock_nullable;
mod m20260622_000004_product_search;
mod m20260622_000005_product_search_aggregate;
mod m20260622_000006_order_search_indexes;
pub struct Migrator;
#[async_trait::async_trait]
@@ -92,6 +93,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260622_000003_variant_stock_nullable::Migration),
Box::new(m20260622_000004_product_search::Migration),
Box::new(m20260622_000005_product_search_aggregate::Migration),
Box::new(m20260622_000006_order_search_indexes::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,48 @@
//! Trigram indexes so the admin order search stays fast as orders pile up.
//!
//! Order search is a plain substring (`ILIKE`) match over the high-signal,
//! free-text order fields — order number, email, customer/company name — run
//! through `f_unaccent` so diacritics and case never matter (see
//! `orders::Entity::search`). These `pg_trgm` GIN indexes let those `ILIKE`
//! lookups use an index instead of scanning every row. `pg_trgm` + `f_unaccent`
//! already exist from the product-search migration.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
CREATE INDEX idx_orders_number_trgm
ON orders USING GIN (f_unaccent(order_number) gin_trgm_ops);
CREATE INDEX idx_orders_email_trgm
ON orders USING GIN (f_unaccent(email) gin_trgm_ops);
CREATE INDEX idx_orders_customer_name_trgm
ON orders USING GIN (f_unaccent(COALESCE(customer_name, '')) gin_trgm_ops);
CREATE INDEX idx_orders_company_name_trgm
ON orders USING GIN (f_unaccent(COALESCE(company_name, '')) gin_trgm_ops);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_orders_company_name_trgm;
DROP INDEX IF EXISTS idx_orders_customer_name_trgm;
DROP INDEX IF EXISTS idx_orders_email_trgm;
DROP INDEX IF EXISTS idx_orders_number_trgm;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -1,5 +1,8 @@
//! Admin order list, detail, status updates, and manual carrier dispatch.
use std::collections::HashMap;
use axum::extract::Query;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -30,18 +33,31 @@ async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
// Optional search over order number / customer / email / etc., otherwise the
// full list newest first.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?
} else {
orders::Entity::search(&ctx.db, &query, 500).await?
};
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
format::view(
&v,
"admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }),
json!({
"orders": rows,
"query": query,
"total": list.len(),
"lang": current_lang(&jar),
}),
)
}

View File

@@ -163,4 +163,45 @@ impl Model {}
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
impl Entity {
/// Admin order search: a diacritic- and case-insensitive substring match over
/// the free-text order fields an admin would actually type — order number,
/// email, customer name, company name, phone and tracking number. Backed by
/// the trigram indexes from the `order_search_indexes` migration. Newest
/// first, capped at `limit`. A blank query returns nothing (callers fall back
/// to the full list).
pub async fn search<C: sea_orm::ConnectionTrait>(
db: &C,
query: &str,
limit: u64,
) -> Result<Vec<Model>, DbErr> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
// Treat the query literally: escape LIKE wildcards, then wrap in %…%.
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT * FROM orders o
WHERE f_unaccent(o.order_number) ILIKE f_unaccent($1)
OR f_unaccent(o.email) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.customer_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.company_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.phone,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.tracking_number,'')) ILIKE f_unaccent($1)
ORDER BY o.created_at DESC
LIMIT $2
"#;
Entity::find()
.from_raw_sql(sea_orm::Statement::from_sql_and_values(
db.get_database_backend(),
sql,
[pattern.into(), (limit as i64).into()],
))
.all(db)
.await
}
}