From 5e6263e8537feb296ddfc9dd5a081f445ad8cad1 Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 22 Jun 2026 21:52:22 +0200 Subject: [PATCH] orders search query also working now --- assets/i18n/en/main.ftl | 1 + assets/i18n/sk/main.ftl | 1 + assets/views/admin/orders/index.html | 19 +++++++- migration/src/lib.rs | 2 + .../m20260622_000006_order_search_indexes.rs | 48 +++++++++++++++++++ src/controllers/admin_orders.rs | 26 ++++++++-- src/models/orders.rs | 43 ++++++++++++++++- 7 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 migration/src/m20260622_000006_order_search_indexes.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 7a3fb24..4234250 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -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 diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 104ce70..0d5e441 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -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ť diff --git a/assets/views/admin/orders/index.html b/assets/views/admin/orders/index.html index be8dffe..e44897f 100644 --- a/assets/views/admin/orders/index.html +++ b/assets/views/admin/orders/index.html @@ -5,7 +5,24 @@ {% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block content %} -

{{ t(key="admin-orders", lang=lang | default(value='sk')) }}

+{% set L = lang | default(value='sk') %} +
+

{{ t(key="admin-orders", lang=L) }}

+ + + +
+ +{% if query and query != "" %} +

{{ t(key="results-count", lang=L, count=total) }} · “{{ query }}”

+{% endif %}
{% if orders | length > 0 %} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index adf9230..19325bd 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -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) ] } diff --git a/migration/src/m20260622_000006_order_search_indexes.rs b/migration/src/m20260622_000006_order_search_indexes.rs new file mode 100644 index 0000000..ef03d23 --- /dev/null +++ b/migration/src/m20260622_000006_order_search_indexes.rs @@ -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(()) + } +} diff --git a/src/controllers/admin_orders.rs b/src/controllers/admin_orders.rs index 59728de..9f7ba4d 100644 --- a/src/controllers/admin_orders.rs +++ b/src/controllers/admin_orders.rs @@ -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, + Query(params): Query>, State(ctx): State, ) -> Result { 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 = 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), + }), ) } diff --git a/src/models/orders.rs b/src/models/orders.rs index 044b4c1..5212550 100644 --- a/src/models/orders.rs +++ b/src/models/orders.rs @@ -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( + db: &C, + query: &str, + limit: u64, + ) -> Result, 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 + } +}