feat: search within deal room

Add debounced search input in deal detail header.
HTMX-powered search across files, folders, and request items.
Returns categorized results with links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:55:22 -05:00
parent 2d6e9fc79a
commit cc9e7eeff1
3 changed files with 89 additions and 0 deletions

View File

@ -302,6 +302,83 @@ func (h *Handler) handleFolderReorder(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) handleDealSearch(w http.ResponseWriter, r *http.Request) {
// /deals/search/{dealID}?q=...
dealID := strings.TrimPrefix(r.URL.Path, "/deals/search/")
q := r.URL.Query().Get("q")
if q == "" {
w.Write([]byte(`<p class="text-sm text-gray-500 italic">Type to search files, folders, and requests.</p>`))
return
}
pattern := "%" + q + "%"
w.Header().Set("Content-Type", "text/html")
html := `<div class="space-y-3">`
// Search files
rows, _ := h.db.Query("SELECT id, name, folder_id FROM files WHERE deal_id = ? AND name LIKE ?", dealID, pattern)
if rows != nil {
hasFiles := false
for rows.Next() {
if !hasFiles {
html += `<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider">Files</h3>`
hasFiles = true
}
var id, name, folderID string
rows.Scan(&id, &name, &folderID)
html += fmt.Sprintf(`<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-800/50">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<a href="/deals/files/download/%s" class="text-sm text-teal-400 hover:underline">%s</a>
</div>`, id, name)
}
rows.Close()
}
// Search folders
rows2, _ := h.db.Query("SELECT id, name FROM folders WHERE deal_id = ? AND name LIKE ?", dealID, pattern)
if rows2 != nil {
hasFolders := false
for rows2.Next() {
if !hasFolders {
html += `<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mt-3">Folders</h3>`
hasFolders = true
}
var id, name string
rows2.Scan(&id, &name)
html += fmt.Sprintf(`<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-800/50">
<svg class="w-4 h-4 text-teal-400/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<a href="/deals/%s?folder=%s" class="text-sm">%s</a>
</div>`, dealID, id, name)
}
rows2.Close()
}
// Search requests
rows3, _ := h.db.Query("SELECT id, item_number, description FROM diligence_requests WHERE deal_id = ? AND description LIKE ?", dealID, pattern)
if rows3 != nil {
hasReqs := false
for rows3.Next() {
if !hasReqs {
html += `<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mt-3">Request Items</h3>`
hasReqs = true
}
var id, itemNum, desc string
rows3.Scan(&id, &itemNum, &desc)
html += fmt.Sprintf(`<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-800/50">
<svg class="w-4 h-4 text-amber-400/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path></svg>
<span class="text-xs text-gray-500">%s</span>
<span class="text-sm">%s</span>
</div>`, itemNum, desc)
}
rows3.Close()
}
html += `</div>`
w.Write([]byte(html))
}
func (h *Handler) getLastActivityByDeal(dealID string) *time.Time {
var t time.Time
err := h.db.QueryRow("SELECT MAX(created_at) FROM deal_activity WHERE deal_id = ?", dealID).Scan(&t)

View File

@ -87,6 +87,7 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/deals/files/delete", h.requireAuth(h.handleFileDelete))
mux.HandleFunc("/deals/files/download/", h.requireAuth(h.handleFileDownload))
mux.HandleFunc("/deals/files/comments/", h.requireAuth(h.handleFileComments))
mux.HandleFunc("/deals/search/", h.requireAuth(h.handleDealSearch))
// HTMX partials
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))

View File

@ -21,6 +21,17 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<p class="text-sm text-gray-500 mt-1">{ deal.TargetCompany } · { deal.Description }</p>
</div>
<!-- Search -->
<div class="relative">
<input type="text" placeholder="Search files, folders, requests..."
hx-get={ fmt.Sprintf("/deals/search/%s", deal.ID) }
hx-trigger="input changed delay:300ms"
hx-target="#searchResults"
name="q"
class="w-full px-4 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none placeholder-gray-600"/>
<div id="searchResults" class="mt-2"></div>
</div>
<!-- Deal Info Cards -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">