From eb103b4813c1d45925d23595f0ce4567e7afc79d Mon Sep 17 00:00:00 2001 From: James Date: Sun, 22 Feb 2026 00:17:03 -0500 Subject: [PATCH] feat: Dealspace UX overhaul - remove closing probability, dashboard stats, last accessed, new room modal, search, per-deal analytics/audit/contacts Co-Authored-By: Claude Opus 4.6 --- internal/handler/analytics.go | 21 +++-- internal/handler/audit.go | 19 ++++- internal/handler/contacts.go | 19 ++++- internal/handler/deals.go | 154 +++++++++++++++++++++++++++++++++- internal/handler/handler.go | 3 + internal/model/models.go | 1 + templates/analytics.templ | 20 +++-- templates/audit.templ | 22 ++++- templates/contacts.templ | 18 ++-- templates/dashboard.templ | 111 ++++++++++++++++++++---- templates/dealroom.templ | 131 +++++++++++++++++++++++------ templates/dealrooms.templ | 2 - 12 files changed, 450 insertions(+), 71 deletions(-) diff --git a/internal/handler/analytics.go b/internal/handler/analytics.go index 0432cee..fe285ce 100644 --- a/internal/handler/analytics.go +++ b/internal/handler/analytics.go @@ -8,12 +8,23 @@ import ( func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) + dealID := r.URL.Query().Get("deal_id") + + deals := h.getDeals(profile) var dealCount, fileCount, requestCount, fulfilledCount int - h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE organization_id = ? AND is_archived = 0", profile.OrganizationID).Scan(&dealCount) - h.db.QueryRow("SELECT COUNT(*) FROM files f JOIN deals d ON f.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&fileCount) - h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&requestCount) - h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ? AND r.atlas_status = 'fulfilled'", profile.OrganizationID).Scan(&fulfilledCount) + + if dealID != "" { + h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE id = ? AND organization_id = ? AND is_archived = 0", dealID, profile.OrganizationID).Scan(&dealCount) + h.db.QueryRow("SELECT COUNT(*) FROM files WHERE deal_id = ?", dealID).Scan(&fileCount) + h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests WHERE deal_id = ?", dealID).Scan(&requestCount) + h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests WHERE deal_id = ? AND atlas_status = 'fulfilled'", dealID).Scan(&fulfilledCount) + } else { + h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE organization_id = ? AND is_archived = 0", profile.OrganizationID).Scan(&dealCount) + h.db.QueryRow("SELECT COUNT(*) FROM files f JOIN deals d ON f.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&fileCount) + h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&requestCount) + h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ? AND r.atlas_status = 'fulfilled'", profile.OrganizationID).Scan(&fulfilledCount) + } completionPct := 0 if requestCount > 0 { @@ -27,5 +38,5 @@ func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) { CompletionPct: completionPct, } - templates.AnalyticsPage(profile, stats).Render(r.Context(), w) + templates.AnalyticsPage(profile, stats, deals, dealID).Render(r.Context(), w) } diff --git a/internal/handler/audit.go b/internal/handler/audit.go index b7c5686..f492368 100644 --- a/internal/handler/audit.go +++ b/internal/handler/audit.go @@ -8,6 +8,21 @@ import ( func (h *Handler) handleAuditLog(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) - activities := h.getActivities(profile.OrganizationID, 50) - templates.AuditLogPage(profile, activities).Render(r.Context(), w) + dealID := r.URL.Query().Get("deal_id") + deals := h.getDeals(profile) + + activities := h.getActivitiesFiltered(profile.OrganizationID, dealID, 50) + + // Populate deal names + dealNames := make(map[string]string) + for _, d := range deals { + dealNames[d.ID] = d.Name + } + for _, act := range activities { + if name, ok := dealNames[act.DealID]; ok { + act.DealName = name + } + } + + templates.AuditLogPage(profile, activities, deals, dealID).Render(r.Context(), w) } diff --git a/internal/handler/contacts.go b/internal/handler/contacts.go index 99c1fe7..df8aeed 100644 --- a/internal/handler/contacts.go +++ b/internal/handler/contacts.go @@ -9,6 +9,8 @@ import ( func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) + dealID := r.URL.Query().Get("deal_id") + deals := h.getDeals(profile) rows, err := h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID) if err != nil { @@ -24,5 +26,20 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) { contacts = append(contacts, c) } - templates.ContactsPage(profile, contacts).Render(r.Context(), w) + // Filter contacts by deal's target company if deal_id is set + if dealID != "" { + var targetCompany string + h.db.QueryRow("SELECT target_company FROM deals WHERE id = ?", dealID).Scan(&targetCompany) + if targetCompany != "" { + var filtered []*model.Contact + for _, c := range contacts { + if c.Company == targetCompany { + filtered = append(filtered, c) + } + } + contacts = filtered + } + } + + templates.ContactsPage(profile, contacts, deals, dealID).Render(r.Context(), w) } diff --git a/internal/handler/deals.go b/internal/handler/deals.go index 5e9c339..4e66aa9 100644 --- a/internal/handler/deals.go +++ b/internal/handler/deals.go @@ -1,8 +1,12 @@ package handler import ( + "encoding/json" + "fmt" "net/http" + "strconv" "strings" + "time" "dealroom/internal/model" "dealroom/internal/rbac" @@ -27,7 +31,27 @@ func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { d.FileCount = count } - templates.Dashboard(profile, deals, activities, fileCounts).Render(r.Context(), w) + // Build deal name map and populate DealName on activities + dealNames := make(map[string]string) + for _, d := range deals { + dealNames[d.ID] = d.Name + } + for _, act := range activities { + if name, ok := dealNames[act.DealID]; ok { + act.DealName = name + } + } + + // Get last activity time per deal + lastActivity := make(map[string]*time.Time) + for _, d := range deals { + t := h.getLastActivityByDeal(d.ID) + if t != nil { + lastActivity[d.ID] = t + } + } + + templates.Dashboard(profile, deals, activities, fileCounts, lastActivity).Render(r.Context(), w) } func (h *Handler) handleDealRooms(w http.ResponseWriter, r *http.Request) { @@ -44,6 +68,17 @@ func (h *Handler) handleDealRooms(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) dealID := strings.TrimPrefix(r.URL.Path, "/deals/") + + // Handle file status PATCH: /deals/{id}/files/{fileID}/status + if strings.Contains(dealID, "/files/") { + parts := strings.SplitN(dealID, "/files/", 2) + dealID = parts[0] + if r.Method == http.MethodPatch { + h.handleFileStatusUpdate(w, r, dealID, parts[1]) + return + } + } + if dealID == "" { http.Redirect(w, r, "/deals", http.StatusSeeOther) return @@ -58,10 +93,93 @@ func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) { } folders := h.getFolders(dealID) + folderParam := r.URL.Query().Get("folder") files := h.getFiles(dealID) requests := h.getRequests(dealID, profile) - templates.DealRoomDetail(profile, &deal, folders, files, requests).Render(r.Context(), w) + templates.DealRoomDetail(profile, &deal, folders, files, requests, folderParam).Render(r.Context(), w) +} + +func (h *Handler) handleCreateDeal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + profile := getProfile(r.Context()) + r.ParseForm() + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Project name is required", 400) + return + } + + targetCompany := strings.TrimSpace(r.FormValue("target_company")) + stage := r.FormValue("stage") + if stage == "" { + stage = "pipeline" + } + dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64) + currency := r.FormValue("currency") + if currency == "" { + currency = "USD" + } + ioiDate := r.FormValue("ioi_date") + loiDate := r.FormValue("loi_date") + description := strings.TrimSpace(r.FormValue("description")) + + id := generateID("deal") + _, err := h.db.Exec(`INSERT INTO deals (id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, profile.ID) + if err != nil { + http.Error(w, fmt.Sprintf("Error creating deal: %v", err), 500) + return + } + + http.Redirect(w, r, "/deals/"+id, http.StatusSeeOther) +} + +func (h *Handler) handleFileStatusUpdate(w http.ResponseWriter, r *http.Request, dealID string, remaining string) { + fileID := strings.TrimSuffix(remaining, "/status") + if fileID == "" { + http.Error(w, "Missing file ID", 400) + return + } + + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", 400) + return + } + + validStatuses := map[string]bool{"uploaded": true, "processing": true, "reviewed": true, "flagged": true, "archived": true} + if !validStatuses[req.Status] { + http.Error(w, "Invalid status", 400) + return + } + + _, err := h.db.Exec("UPDATE files SET status = ? WHERE id = ? AND deal_id = ?", req.Status, fileID, dealID) + if err != nil { + http.Error(w, "Error updating status", 500) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": req.Status}) +} + +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) + if err != nil { + return nil + } + if t.IsZero() { + return nil + } + return &t } func (h *Handler) getDeals(profile *model.Profile) []*model.Deal { @@ -97,7 +215,7 @@ func (h *Handler) getFolders(dealID string) []*model.Folder { } func (h *Handler) getFiles(dealID string) []*model.File { - rows, err := h.db.Query("SELECT id, deal_id, folder_id, name, file_size, mime_type, status, uploaded_by FROM files WHERE deal_id = ? ORDER BY name", dealID) + rows, err := h.db.Query("SELECT id, deal_id, folder_id, name, file_size, mime_type, status, uploaded_by, created_at FROM files WHERE deal_id = ? ORDER BY name", dealID) if err != nil { return nil } @@ -106,7 +224,7 @@ func (h *Handler) getFiles(dealID string) []*model.File { var files []*model.File for rows.Next() { f := &model.File{} - rows.Scan(&f.ID, &f.DealID, &f.FolderID, &f.Name, &f.FileSize, &f.MimeType, &f.Status, &f.UploadedBy) + rows.Scan(&f.ID, &f.DealID, &f.FolderID, &f.Name, &f.FileSize, &f.MimeType, &f.Status, &f.UploadedBy, &f.CreatedAt) files = append(files, f) } return files @@ -164,3 +282,31 @@ func (h *Handler) getActivities(orgID string, limit int) []*model.DealActivity { } return acts } + +func (h *Handler) getActivitiesFiltered(orgID string, dealID string, limit int) []*model.DealActivity { + query := ` + SELECT a.id, a.deal_id, a.user_id, a.activity_type, a.resource_type, a.resource_name, a.created_at, COALESCE(p.full_name, 'Unknown') + FROM deal_activity a LEFT JOIN profiles p ON a.user_id = p.id + WHERE a.organization_id = ?` + args := []interface{}{orgID} + if dealID != "" { + query += " AND a.deal_id = ?" + args = append(args, dealID) + } + query += " ORDER BY a.created_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := h.db.Query(query, args...) + if err != nil { + return nil + } + defer rows.Close() + + var acts []*model.DealActivity + for rows.Next() { + a := &model.DealActivity{} + rows.Scan(&a.ID, &a.DealID, &a.UserID, &a.ActivityType, &a.ResourceType, &a.ResourceName, &a.CreatedAt, &a.UserName) + acts = append(acts, a) + } + return acts +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index aa0ea05..0faea99 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -67,6 +67,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave)) mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete)) + // Deal creation + mux.HandleFunc("/deals/create", h.requireAuth(h.handleCreateDeal)) + // HTMX partials mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment)) } diff --git a/internal/model/models.go b/internal/model/models.go index f8bf03b..2cebaba 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -124,6 +124,7 @@ type DealActivity struct { CreatedAt time.Time // Computed UserName string + DealName string } type Session struct { diff --git a/templates/analytics.templ b/templates/analytics.templ index e9c95dd..ef18d30 100644 --- a/templates/analytics.templ +++ b/templates/analytics.templ @@ -10,12 +10,22 @@ type AnalyticsStats struct { CompletionPct int } -templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats) { +templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats, deals []*model.Deal, selectedDealID string) { @Layout(profile, "analytics") {
-
-

Analytics

-

Key metrics and insights across your deal portfolio.

+
+
+

Analytics

+

Key metrics and insights across your deal portfolio.

+
+
+ +
@@ -35,7 +45,7 @@ templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats) {
Request Completion
{ fmt.Sprintf("%d%%", stats.CompletionPct) }
-
 
+
diff --git a/templates/audit.templ b/templates/audit.templ index da65c53..e0bb5f5 100644 --- a/templates/audit.templ +++ b/templates/audit.templ @@ -1,13 +1,24 @@ package templates import "dealroom/internal/model" +import "fmt" -templ AuditLogPage(profile *model.Profile, activities []*model.DealActivity) { +templ AuditLogPage(profile *model.Profile, activities []*model.DealActivity, deals []*model.Deal, selectedDealID string) { @Layout(profile, "audit") {
-
-

Audit Log

-

Complete activity timeline across all deal rooms.

+
+
+

Audit Log

+

Complete activity timeline across all deal rooms.

+
+
+ +
@@ -30,6 +41,9 @@ templ AuditLogPage(profile *model.Profile, activities []*model.DealActivity) { templ.KV("bg-gray-700 text-gray-400", act.ActivityType != "upload" && act.ActivityType != "view" && act.ActivityType != "edit" && act.ActivityType != "download") }> { act.ActivityType } + if act.DealName != "" { + { act.DealName } + }

{ act.ResourceType }: { act.ResourceName }

{ act.CreatedAt.Format("Jan 2, 2006 3:04 PM") }

diff --git a/templates/contacts.templ b/templates/contacts.templ index 1ddaa7c..e5f7386 100644 --- a/templates/contacts.templ +++ b/templates/contacts.templ @@ -4,7 +4,7 @@ import "dealroom/internal/model" import "fmt" import "strings" -templ ContactsPage(profile *model.Profile, contacts []*model.Contact) { +templ ContactsPage(profile *model.Profile, contacts []*model.Contact, deals []*model.Deal, selectedDealID string) { @Layout(profile, "contacts") {
@@ -12,10 +12,18 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact) {

Contacts

{ fmt.Sprintf("%d contacts", len(contacts)) } across all deal rooms.

- +
+ + +
diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 77b0282..78162c7 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -2,8 +2,9 @@ package templates import "dealroom/internal/model" import "fmt" +import "time" -templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model.DealActivity, fileCounts map[string]int) { +templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model.DealActivity, fileCounts map[string]int, lastActivity map[string]*time.Time) { @Layout(profile, "dashboard") {
@@ -12,18 +13,18 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model

Dashboard

Overview of all active deal rooms and recent activity.

- +
@statCard("ACTIVE ROOMS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d total", len(deals)), "folder") - @statCard("DOCUMENTS", fmt.Sprintf("%d", totalFiles(fileCounts)), "across all rooms", "file") - @statCard("ACTIVE DEALS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d in diligence", countByStage(deals, "due_diligence")), "users") - @statCard("AVG. CLOSE PROB.", fmt.Sprintf("%d%%", avgProbability(deals)), "across portfolio", "trend") + @statCard("PRE-MARKETING", fmt.Sprintf("%d", countByStage(deals, "pipeline")), "in pipeline", "file") + @statCard("IOI STAGE", fmt.Sprintf("%d", countIOIStage(deals)), "initial review / LOI", "users") + @statCard("CLOSED", fmt.Sprintf("%d", countByStage(deals, "closed")), "deals closed", "trend")
@@ -55,7 +56,7 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model

{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }

-
{ fmt.Sprintf("%d%%", deal.CloseProbability) }
+
{ formatLastAccessed(lastActivity[deal.ID]) }
}
@@ -80,7 +81,13 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model { act.ActivityType } { act.ResourceName }

-

{ act.UserName } · { act.CreatedAt.Format("Jan 2, 3:04 PM") }

+

+ { act.UserName } · { act.CreatedAt.Format("Jan 2, 3:04 PM") } + if act.DealName != "" { + · + { act.DealName } + } +

} @@ -88,6 +95,72 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model + + + } } @@ -111,6 +184,16 @@ func countByStage(deals []*model.Deal, stage string) int { return count } +func countIOIStage(deals []*model.Deal) int { + count := 0 + for _, d := range deals { + if d.Stage == "loi" || d.Stage == "initial_review" { + count++ + } + } + return count +} + func totalFiles(fc map[string]int) int { total := 0 for _, c := range fc { @@ -119,15 +202,11 @@ func totalFiles(fc map[string]int) int { return total } -func avgProbability(deals []*model.Deal) int { - if len(deals) == 0 { - return 0 +func formatLastAccessed(t *time.Time) string { + if t == nil { + return "Never accessed" } - sum := 0 - for _, d := range deals { - sum += d.CloseProbability - } - return sum / len(deals) + return "Last accessed " + t.Format("Jan 2") } templ statCard(label, value, subtitle, iconType string) { diff --git a/templates/dealroom.templ b/templates/dealroom.templ index ae0e818..e4c87fc 100644 --- a/templates/dealroom.templ +++ b/templates/dealroom.templ @@ -2,8 +2,9 @@ package templates import "dealroom/internal/model" import "fmt" +import "time" -templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.Folder, files []*model.File, requests []*model.DiligenceRequest) { +templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.Folder, files []*model.File, requests []*model.DiligenceRequest, activeFolder string) { @Layout(profile, "deals") {
@@ -20,22 +21,18 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
-
+
Deal Size
{ formatDealSize(deal.DealSize) }
-
-
Close Probability
-
{ fmt.Sprintf("%d%%", deal.CloseProbability) }
-
IOI Date
if deal.IOIDate != "" { { deal.IOIDate } } else { - + }
@@ -45,14 +42,14 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model. if deal.ExclusivityEnd != "" { { deal.ExclusivityEnd } } else { - + }
-
+
+} + func formatFileSize(bytes int64) string { if bytes < 1024 { return fmt.Sprintf("%d B", bytes) @@ -185,6 +251,17 @@ func formatFileSize(bytes int64) string { return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) } +func formatFileDate(t time.Time) string { + if t.IsZero() { + return "" + } + now := time.Now() + if t.Year() == now.Year() { + return t.Format("Jan 2") + } + return t.Format("Jan 2, 2006") +} + templ fileIcon(name string) {
Stage Deal Size Documents - Close Prob. IOI Date LOI Date @@ -42,7 +41,6 @@ templ DealRooms(profile *model.Profile, deals []*model.Deal) { @StageBadge(deal.Stage) { formatDealSize(deal.DealSize) } { fmt.Sprintf("%d files", deal.FileCount) } - { fmt.Sprintf("%d%%", deal.CloseProbability) } { deal.IOIDate } { deal.LOIDate }