orders search query also working now
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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ť
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal file
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user