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
+
+
+
+
+
+
+
Create New Room
+
+
+
+
+
}
}
@@ -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 {
-
—
+
—
}
-
+