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
+ }
+}