feat: add product search with HTMX live filtering
- Search purchasers by product name, store, or notes (case-insensitive) - GET /search?q=... returns purchase list fragment - 300ms debounced keyup trigger on search input - Empty query returns all purchases
This commit is contained in:
parent
f0c576c543
commit
e862d494aa
4 changed files with 84 additions and 0 deletions
|
|
@ -312,6 +312,45 @@ func UpdatePurchase(db *sql.DB, p Purchase) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchPurchases searches purchases by product_name, store, or notes (case-insensitive).
|
||||||
|
// When query is empty, falls back to GetPurchasesByUser.
|
||||||
|
func SearchPurchases(db *sql.DB, userID, query string) ([]Purchase, error) {
|
||||||
|
if query == "" {
|
||||||
|
return GetPurchasesByUser(db, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, user_id, product_name, store, category, amount, currency,
|
||||||
|
purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at
|
||||||
|
FROM purchases WHERE user_id = ?
|
||||||
|
AND (product_name LIKE ? OR store LIKE ? OR notes LIKE ?)
|
||||||
|
ORDER BY purchase_date DESC, created_at DESC`,
|
||||||
|
userID, "%"+query+"%", "%"+query+"%", "%"+query+"%",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: SearchPurchases(%s, %s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), userID, query, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var purchases []Purchase
|
||||||
|
for rows.Next() {
|
||||||
|
var p Purchase
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category,
|
||||||
|
&p.Amount, &p.Currency, &p.PurchaseDate,
|
||||||
|
&p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: SearchPurchases scan: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
purchases = append(purchases, p)
|
||||||
|
}
|
||||||
|
return purchases, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// DeletePurchase removes a single purchase by its ID.
|
// DeletePurchase removes a single purchase by its ID.
|
||||||
func DeletePurchase(db *sql.DB, id string) error {
|
func DeletePurchase(db *sql.DB, id string) error {
|
||||||
_, err := db.Exec("DELETE FROM purchases WHERE id = ?", id)
|
_, err := db.Exec("DELETE FROM purchases WHERE id = ?", id)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -58,6 +59,7 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"Purchases": purchases,
|
"Purchases": purchases,
|
||||||
|
"Query": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
@ -66,3 +68,35 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
time.Now().Format(time.RFC3339), err)
|
time.Now().Format(time.RFC3339), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /search — SearchPurchases
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SearchPurchases handles the HTMX search request, returning only the purchase
|
||||||
|
// list fragment filtered by the search query.
|
||||||
|
func (h *DashboardHandler) SearchPurchases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
|
||||||
|
purchases, err := database.SearchPurchases(h.DB, userID, query)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SearchPurchases: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Search failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize ImagePath for all purchases.
|
||||||
|
for i := range purchases {
|
||||||
|
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, renderPurchaseList(purchases))
|
||||||
|
}
|
||||||
|
|
|
||||||
3
main.go
3
main.go
|
|
@ -200,6 +200,9 @@ func main() {
|
||||||
// Dashboard.
|
// Dashboard.
|
||||||
r.Get("/dashboard", dashboardHandler.Dashboard)
|
r.Get("/dashboard", dashboardHandler.Dashboard)
|
||||||
|
|
||||||
|
// Search.
|
||||||
|
r.Get("/search", dashboardHandler.SearchPurchases)
|
||||||
|
|
||||||
// Purchases.
|
// Purchases.
|
||||||
r.Post("/purchases/upload", purchaseHandler.UploadReceipt)
|
r.Post("/purchases/upload", purchaseHandler.UploadReceipt)
|
||||||
r.Post("/purchases", purchaseHandler.SavePurchase)
|
r.Post("/purchases", purchaseHandler.SavePurchase)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@
|
||||||
<h2>My Purchases</h2>
|
<h2>My Purchases</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<input type="text" id="search" name="q" placeholder="Search products, stores, notes..."
|
||||||
|
value="{{.Query}}"
|
||||||
|
hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#purchase-list"
|
||||||
|
style="width: 100%%; padding: 0.75rem 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-card); color: var(--color-text); font-size: 0.9rem; box-sizing: border-box;">
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Purchase List -->
|
<!-- Purchase List -->
|
||||||
<div id="purchase-list">
|
<div id="purchase-list">
|
||||||
{{if .Purchases}}
|
{{if .Purchases}}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue