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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-22 00:17:03 -05:00
parent e4552aef5b
commit eb103b4813
12 changed files with 450 additions and 71 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -124,6 +124,7 @@ type DealActivity struct {
CreatedAt time.Time
// Computed
UserName string
DealName string
}
type Session struct {

View File

@ -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") {
<div class="space-y-5">
<div>
<h1 class="text-2xl font-bold">Analytics</h1>
<p class="text-sm text-gray-500 mt-1">Key metrics and insights across your deal portfolio.</p>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Analytics</h1>
<p class="text-sm text-gray-500 mt-1">Key metrics and insights across your deal portfolio.</p>
</div>
<div>
<select onchange="window.location.href='/analytics?deal_id='+this.value" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="">All Deals</option>
for _, deal := range deals {
<option value={ deal.ID } selected?={ deal.ID == selectedDealID }>{ deal.Name }</option>
}
</select>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
@ -35,7 +45,7 @@ templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats) {
<div class="text-xs text-gray-500 uppercase tracking-wider mb-2">Request Completion</div>
<div class="text-3xl font-bold text-green-400">{ fmt.Sprintf("%d%%", stats.CompletionPct) }</div>
<div class="mt-3 w-full bg-gray-800 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 50%">&nbsp;</div>
<div class="bg-green-500 h-2 rounded-full" style="width: 50%"> </div>
</div>
</div>
</div>

View File

@ -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") {
<div class="space-y-5">
<div>
<h1 class="text-2xl font-bold">Audit Log</h1>
<p class="text-sm text-gray-500 mt-1">Complete activity timeline across all deal rooms.</p>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Audit Log</h1>
<p class="text-sm text-gray-500 mt-1">Complete activity timeline across all deal rooms.</p>
</div>
<div>
<select onchange="window.location.href='/audit?deal_id='+this.value" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="">All Deals</option>
for _, deal := range deals {
<option value={ deal.ID } selected?={ deal.ID == selectedDealID }>{ deal.Name }</option>
}
</select>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
@ -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 }
</span>
if act.DealName != "" {
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s", act.DealID)) } class="text-xs text-teal-400 hover:underline">{ act.DealName }</a>
}
</div>
<p class="text-sm text-gray-400 mt-0.5">{ act.ResourceType }: { act.ResourceName }</p>
<p class="text-xs text-gray-600 mt-1">{ act.CreatedAt.Format("Jan 2, 2006 3:04 PM") }</p>

View File

@ -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") {
<div class="space-y-5">
<div class="flex items-center justify-between">
@ -12,10 +12,18 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact) {
<h1 class="text-2xl font-bold">Contacts</h1>
<p class="text-sm text-gray-500 mt-1">{ fmt.Sprintf("%d contacts", len(contacts)) } across all deal rooms.</p>
</div>
<button class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
Add Contact
</button>
<div class="flex items-center gap-3">
<select onchange="window.location.href='/contacts?deal_id='+this.value" class="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="">All Deals</option>
for _, deal := range deals {
<option value={ deal.ID } selected?={ deal.ID == selectedDealID }>{ deal.Name }</option>
}
</select>
<button class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
Add Contact
</button>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">

View File

@ -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") {
<div class="space-y-6">
<!-- Header -->
@ -12,18 +13,18 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="text-sm text-gray-500 mt-1">Overview of all active deal rooms and recent activity.</p>
</div>
<a href="/deals" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<button onclick="document.getElementById('newRoomModal').classList.remove('hidden')" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Room
</a>
</button>
</div>
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
@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")
</div>
<!-- Content Grid -->
@ -55,7 +56,7 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
</div>
<p class="text-xs text-gray-500 mt-0.5">{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }</p>
</div>
<div class="text-xs text-gray-500">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</div>
<div class="text-xs text-gray-500">{ formatLastAccessed(lastActivity[deal.ID]) }</div>
</a>
}
</div>
@ -80,7 +81,13 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
<span class="font-medium">{ act.ActivityType }</span>
<span class="text-gray-400"> { act.ResourceName }</span>
</p>
<p class="text-xs text-gray-500 mt-0.5">{ act.UserName } · { act.CreatedAt.Format("Jan 2, 3:04 PM") }</p>
<p class="text-xs text-gray-500 mt-0.5">
{ act.UserName } · { act.CreatedAt.Format("Jan 2, 3:04 PM") }
if act.DealName != "" {
<span class="text-gray-600"> · </span>
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s", act.DealID)) } class="text-teal-400 hover:underline">{ act.DealName }</a>
}
</p>
</div>
</div>
}
@ -88,6 +95,72 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
</div>
</div>
</div>
<!-- New Room Modal -->
<div id="newRoomModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('newRoomModal').classList.add('hidden')"></div>
<div class="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold">Create New Room</h2>
<button onclick="document.getElementById('newRoomModal').classList.add('hidden')" class="text-gray-500 hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<form action="/deals/create" method="POST" class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Project Name <span class="text-red-400">*</span></label>
<input type="text" name="name" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Company Name</label>
<input type="text" name="target_company" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Stage</label>
<select name="stage" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="pipeline">Pipeline</option>
<option value="loi">LOI Stage</option>
<option value="initial_review">Initial Review</option>
<option value="due_diligence">Due Diligence</option>
<option value="final_negotiation">Final Negotiation</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Deal Size</label>
<input type="number" name="deal_size" step="0.01" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Currency</label>
<select name="currency" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">IOI Date</label>
<input type="date" name="ioi_date" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">LOI Date</label>
<input type="date" name="loi_date" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Description</label>
<textarea name="description" rows="2" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none resize-none"></textarea>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="document.getElementById('newRoomModal').classList.add('hidden')" class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-600 transition">Cancel</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Create Room</button>
</div>
</form>
</div>
</div>
}
}
@ -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) {

View File

@ -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") {
<div class="space-y-5">
<!-- Header -->
@ -20,22 +21,18 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
<!-- Deal Info Cards -->
<div class="grid grid-cols-4 gap-4">
<div class="grid grid-cols-3 gap-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Deal Size</div>
<div class="text-lg font-bold">{ formatDealSize(deal.DealSize) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Close Probability</div>
<div class="text-lg font-bold">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">IOI Date</div>
<div class="text-lg font-bold">
if deal.IOIDate != "" {
{ deal.IOIDate }
} else {
<span class="text-gray-600"></span>
<span class="text-gray-600">&mdash;</span>
}
</div>
</div>
@ -45,14 +42,14 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
if deal.ExclusivityEnd != "" {
{ deal.ExclusivityEnd }
} else {
<span class="text-gray-600"></span>
<span class="text-gray-600">&mdash;</span>
}
</div>
</div>
</div>
<!-- Tabs -->
<div x-data="{ tab: 'documents' }">
<div>
<div class="flex gap-1 border-b border-gray-800 mb-4">
<button onclick="showTab('documents')" id="tab-documents" class="px-4 py-2 text-sm font-medium border-b-2 border-teal-500 text-teal-400">
Documents ({ fmt.Sprintf("%d", len(files)) })
@ -68,21 +65,28 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<!-- Folder Tree -->
<div class="bg-gray-900 rounded-lg border border-gray-800 p-3">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Folders</h3>
<!-- All Documents -->
<div class="mb-1">
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=all", deal.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == "all"), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != "all") }>
<svg class="w-4 h-4 text-teal-400/70 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path></svg>
<span class="truncate">All Documents</span>
</a>
</div>
for _, folder := range folders {
if folder.ParentID == "" {
<div class="mb-1">
<div class="flex items-center gap-1.5 py-1.5 px-2 rounded hover:bg-gray-800 text-sm cursor-pointer">
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, folder.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == folder.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != folder.ID) }>
<svg class="w-4 h-4 text-teal-400/70 shrink-0" 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>
<span class="truncate text-gray-300">{ folder.Name }</span>
</div>
<span class="truncate">{ folder.Name }</span>
</a>
<!-- Child folders -->
for _, child := range folders {
if child.ParentID == folder.ID {
<div class="ml-4">
<div class="flex items-center gap-1.5 py-1.5 px-2 rounded hover:bg-gray-800 text-sm cursor-pointer">
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, child.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == child.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != child.ID) }>
<svg class="w-4 h-4 text-teal-400/70 shrink-0" 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>
<span class="truncate text-gray-300">{ child.Name }</span>
</div>
<span class="truncate">{ child.Name }</span>
</a>
</div>
}
}
@ -93,26 +97,47 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<!-- Files Table -->
<div class="col-span-3 bg-gray-900 rounded-lg border border-gray-800">
<!-- Search -->
<div class="p-3 border-b border-gray-800">
<input type="text" id="fileSearch" placeholder="Search files..." oninput="filterFiles()" class="w-full px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider">File Name</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Size</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Status</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Date</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-28">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
<tbody id="fileTableBody" class="divide-y divide-gray-800/50">
for _, file := range files {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
@fileIcon(file.Name)
<span class="text-sm">{ file.Name }</span>
</div>
</td>
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileSize(file.FileSize) }</td>
<td class="px-4 py-2.5">@FileStatusBadge(file.Status)</td>
</tr>
if activeFolder == "" || activeFolder == "all" || file.FolderID == activeFolder {
<tr class="hover:bg-gray-800/30 transition file-row" data-filename={ file.Name }>
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
@fileIcon(file.Name)
<span class="text-sm">{ file.Name }</span>
</div>
</td>
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileSize(file.FileSize) }</td>
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileDate(file.CreatedAt) }</td>
<td class="px-4 py-2.5">
<div class="relative status-dropdown">
<button onclick={ templ.ComponentScript{Call: fmt.Sprintf("toggleStatusDropdown(event, '%s', '%s')", deal.ID, file.ID)} } class="cursor-pointer">
@FileStatusBadge(file.Status)
</button>
<div class="hidden absolute z-10 mt-1 right-0 w-36 bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1" data-dropdown={ file.ID }>
@statusOption(deal.ID, file.ID, "uploaded", "Uploaded")
@statusOption(deal.ID, file.ID, "processing", "Processing")
@statusOption(deal.ID, file.ID, "reviewed", "Reviewed")
@statusOption(deal.ID, file.ID, "flagged", "Flagged")
@statusOption(deal.ID, file.ID, "archived", "Archived")
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
@ -170,11 +195,52 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
? 'px-4 py-2 text-sm font-medium border-b-2 border-teal-500 text-teal-400'
: 'px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-300';
}
function filterFiles() {
var q = document.getElementById('fileSearch').value.toLowerCase();
document.querySelectorAll('.file-row').forEach(function(row) {
var name = row.getAttribute('data-filename').toLowerCase();
row.style.display = name.includes(q) ? '' : 'none';
});
}
function toggleStatusDropdown(event, dealID, fileID) {
event.stopPropagation();
document.querySelectorAll('[data-dropdown]').forEach(function(el) {
if (el.getAttribute('data-dropdown') !== fileID) {
el.classList.add('hidden');
}
});
var dd = document.querySelector('[data-dropdown="' + fileID + '"]');
dd.classList.toggle('hidden');
}
function updateFileStatus(dealID, fileID, status) {
fetch('/deals/' + dealID + '/files/' + fileID + '/status', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function() {
window.location.reload();
});
}
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown]').forEach(function(el) {
el.classList.add('hidden');
});
});
</script>
</div>
}
}
templ statusOption(dealID string, fileID string, status string, label string) {
<button onclick={ templ.ComponentScript{Call: fmt.Sprintf("updateFileStatus('%s', '%s', '%s')", dealID, fileID, status)} } class="block w-full text-left px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700 transition">
{ label }
</button>
}
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) {
<div class={ "w-7 h-7 rounded flex items-center justify-center text-xs font-semibold text-white",
templ.KV("bg-red-500", hasSuffix(name, ".pdf")),

View File

@ -22,7 +22,6 @@ templ DealRooms(profile *model.Profile, deals []*model.Deal) {
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Stage</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Deal Size</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Close Prob.</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">IOI Date</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">LOI Date</th>
</tr>
@ -42,7 +41,6 @@ templ DealRooms(profile *model.Profile, deals []*model.Deal) {
<td class="px-4 py-3">@StageBadge(deal.Stage)</td>
<td class="px-4 py-3 text-sm text-gray-300">{ formatDealSize(deal.DealSize) }</td>
<td class="px-4 py-3 text-sm text-gray-400">{ fmt.Sprintf("%d files", deal.FileCount) }</td>
<td class="px-4 py-3 text-sm text-gray-300">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</td>
<td class="px-4 py-3 text-xs text-gray-500">{ deal.IOIDate }</td>
<td class="px-4 py-3 text-xs text-gray-500">{ deal.LOIDate }</td>
</tr>