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") {
Key metrics and insights across your deal portfolio.
+Key metrics and insights across your deal portfolio.
+Complete activity timeline across all deal rooms.
+Complete activity timeline across all deal rooms.
+{ 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") {{ fmt.Sprintf("%d contacts", len(contacts)) } across all deal rooms.
Overview of all active deal rooms and recent activity.
{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }
{ 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 + + +