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:
parent
e4552aef5b
commit
eb103b4813
|
|
@ -8,12 +8,23 @@ import (
|
||||||
|
|
||||||
func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||||
profile := getProfile(r.Context())
|
profile := getProfile(r.Context())
|
||||||
|
dealID := r.URL.Query().Get("deal_id")
|
||||||
|
|
||||||
|
deals := h.getDeals(profile)
|
||||||
|
|
||||||
var dealCount, fileCount, requestCount, fulfilledCount int
|
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)
|
if dealID != "" {
|
||||||
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 deals WHERE id = ? AND organization_id = ? AND is_archived = 0", dealID, profile.OrganizationID).Scan(&dealCount)
|
||||||
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)
|
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
|
completionPct := 0
|
||||||
if requestCount > 0 {
|
if requestCount > 0 {
|
||||||
|
|
@ -27,5 +38,5 @@ func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||||
CompletionPct: completionPct,
|
CompletionPct: completionPct,
|
||||||
}
|
}
|
||||||
|
|
||||||
templates.AnalyticsPage(profile, stats).Render(r.Context(), w)
|
templates.AnalyticsPage(profile, stats, deals, dealID).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@ import (
|
||||||
|
|
||||||
func (h *Handler) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
profile := getProfile(r.Context())
|
profile := getProfile(r.Context())
|
||||||
activities := h.getActivities(profile.OrganizationID, 50)
|
dealID := r.URL.Query().Get("deal_id")
|
||||||
templates.AuditLogPage(profile, activities).Render(r.Context(), w)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import (
|
||||||
|
|
||||||
func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
|
||||||
profile := getProfile(r.Context())
|
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)
|
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 {
|
if err != nil {
|
||||||
|
|
@ -24,5 +26,20 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
|
||||||
contacts = append(contacts, c)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"dealroom/internal/model"
|
"dealroom/internal/model"
|
||||||
"dealroom/internal/rbac"
|
"dealroom/internal/rbac"
|
||||||
|
|
@ -27,7 +31,27 @@ func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
d.FileCount = count
|
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) {
|
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) {
|
func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
profile := getProfile(r.Context())
|
profile := getProfile(r.Context())
|
||||||
dealID := strings.TrimPrefix(r.URL.Path, "/deals/")
|
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 == "" {
|
if dealID == "" {
|
||||||
http.Redirect(w, r, "/deals", http.StatusSeeOther)
|
http.Redirect(w, r, "/deals", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
|
|
@ -58,10 +93,93 @@ func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
folders := h.getFolders(dealID)
|
folders := h.getFolders(dealID)
|
||||||
|
folderParam := r.URL.Query().Get("folder")
|
||||||
files := h.getFiles(dealID)
|
files := h.getFiles(dealID)
|
||||||
requests := h.getRequests(dealID, profile)
|
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 {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +224,7 @@ func (h *Handler) getFiles(dealID string) []*model.File {
|
||||||
var files []*model.File
|
var files []*model.File
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
f := &model.File{}
|
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)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
|
|
@ -164,3 +282,31 @@ func (h *Handler) getActivities(orgID string, limit int) []*model.DealActivity {
|
||||||
}
|
}
|
||||||
return acts
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave))
|
mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave))
|
||||||
mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete))
|
mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete))
|
||||||
|
|
||||||
|
// Deal creation
|
||||||
|
mux.HandleFunc("/deals/create", h.requireAuth(h.handleCreateDeal))
|
||||||
|
|
||||||
// HTMX partials
|
// HTMX partials
|
||||||
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))
|
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ type DealActivity struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
// Computed
|
// Computed
|
||||||
UserName string
|
UserName string
|
||||||
|
DealName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,22 @@ type AnalyticsStats struct {
|
||||||
CompletionPct int
|
CompletionPct int
|
||||||
}
|
}
|
||||||
|
|
||||||
templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats) {
|
templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats, deals []*model.Deal, selectedDealID string) {
|
||||||
@Layout(profile, "analytics") {
|
@Layout(profile, "analytics") {
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Analytics</h1>
|
<div>
|
||||||
<p class="text-sm text-gray-500 mt-1">Key metrics and insights across your deal portfolio.</p>
|
<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>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<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-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="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="mt-3 w-full bg-gray-800 rounded-full h-2">
|
||||||
<div class="bg-green-500 h-2 rounded-full" style="width: 50%"> </div>
|
<div class="bg-green-500 h-2 rounded-full" style="width: 50%"> </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import "dealroom/internal/model"
|
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") {
|
@Layout(profile, "audit") {
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Audit Log</h1>
|
<div>
|
||||||
<p class="text-sm text-gray-500 mt-1">Complete activity timeline across all deal rooms.</p>
|
<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>
|
||||||
|
|
||||||
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
|
<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") }>
|
templ.KV("bg-gray-700 text-gray-400", act.ActivityType != "upload" && act.ActivityType != "view" && act.ActivityType != "edit" && act.ActivityType != "download") }>
|
||||||
{ act.ActivityType }
|
{ act.ActivityType }
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p class="text-sm text-gray-400 mt-0.5">{ act.ResourceType }: { act.ResourceName }</p>
|
<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>
|
<p class="text-xs text-gray-600 mt-1">{ act.CreatedAt.Format("Jan 2, 2006 3:04 PM") }</p>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "dealroom/internal/model"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
import "strings"
|
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") {
|
@Layout(profile, "contacts") {
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
<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>
|
<p class="text-sm text-gray-500 mt-1">{ fmt.Sprintf("%d contacts", len(contacts)) } across all deal rooms.</p>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-3">
|
||||||
<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>
|
<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">
|
||||||
Add Contact
|
<option value="">All Deals</option>
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div class="bg-gray-900 rounded-lg border border-gray-800">
|
<div class="bg-gray-900 rounded-lg border border-gray-800">
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ package templates
|
||||||
|
|
||||||
import "dealroom/internal/model"
|
import "dealroom/internal/model"
|
||||||
import "fmt"
|
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") {
|
@Layout(profile, "dashboard") {
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|
@ -12,18 +13,18 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
|
||||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
<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>
|
<p class="text-sm text-gray-500 mt-1">Overview of all active deal rooms and recent activity.</p>
|
||||||
</div>
|
</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>
|
<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
|
New Room
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
@statCard("ACTIVE ROOMS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d total", len(deals)), "folder")
|
@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("PRE-MARKETING", fmt.Sprintf("%d", countByStage(deals, "pipeline")), "in pipeline", "file")
|
||||||
@statCard("ACTIVE DEALS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d in diligence", countByStage(deals, "due_diligence")), "users")
|
@statCard("IOI STAGE", fmt.Sprintf("%d", countIOIStage(deals)), "initial review / LOI", "users")
|
||||||
@statCard("AVG. CLOSE PROB.", fmt.Sprintf("%d%%", avgProbability(deals)), "across portfolio", "trend")
|
@statCard("CLOSED", fmt.Sprintf("%d", countByStage(deals, "closed")), "deals closed", "trend")
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Grid -->
|
<!-- Content Grid -->
|
||||||
|
|
@ -55,7 +56,7 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-0.5">{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }</p>
|
<p class="text-xs text-gray-500 mt-0.5">{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }</p>
|
||||||
</div>
|
</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>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,7 +81,13 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
|
||||||
<span class="font-medium">{ act.ActivityType }</span>
|
<span class="font-medium">{ act.ActivityType }</span>
|
||||||
<span class="text-gray-400"> { act.ResourceName }</span>
|
<span class="text-gray-400"> { act.ResourceName }</span>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +95,72 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
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 {
|
func totalFiles(fc map[string]int) int {
|
||||||
total := 0
|
total := 0
|
||||||
for _, c := range fc {
|
for _, c := range fc {
|
||||||
|
|
@ -119,15 +202,11 @@ func totalFiles(fc map[string]int) int {
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
func avgProbability(deals []*model.Deal) int {
|
func formatLastAccessed(t *time.Time) string {
|
||||||
if len(deals) == 0 {
|
if t == nil {
|
||||||
return 0
|
return "Never accessed"
|
||||||
}
|
}
|
||||||
sum := 0
|
return "Last accessed " + t.Format("Jan 2")
|
||||||
for _, d := range deals {
|
|
||||||
sum += d.CloseProbability
|
|
||||||
}
|
|
||||||
return sum / len(deals)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ statCard(label, value, subtitle, iconType string) {
|
templ statCard(label, value, subtitle, iconType string) {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ package templates
|
||||||
|
|
||||||
import "dealroom/internal/model"
|
import "dealroom/internal/model"
|
||||||
import "fmt"
|
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") {
|
@Layout(profile, "deals") {
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|
@ -20,22 +21,18 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deal Info Cards -->
|
<!-- 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="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-xs text-gray-500 uppercase tracking-wider mb-1">Deal Size</div>
|
||||||
<div class="text-lg font-bold">{ formatDealSize(deal.DealSize) }</div>
|
<div class="text-lg font-bold">{ formatDealSize(deal.DealSize) }</div>
|
||||||
</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="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-xs text-gray-500 uppercase tracking-wider mb-1">IOI Date</div>
|
||||||
<div class="text-lg font-bold">
|
<div class="text-lg font-bold">
|
||||||
if deal.IOIDate != "" {
|
if deal.IOIDate != "" {
|
||||||
{ deal.IOIDate }
|
{ deal.IOIDate }
|
||||||
} else {
|
} else {
|
||||||
<span class="text-gray-600">—</span>
|
<span class="text-gray-600">—</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,14 +42,14 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
||||||
if deal.ExclusivityEnd != "" {
|
if deal.ExclusivityEnd != "" {
|
||||||
{ deal.ExclusivityEnd }
|
{ deal.ExclusivityEnd }
|
||||||
} else {
|
} else {
|
||||||
<span class="text-gray-600">—</span>
|
<span class="text-gray-600">—</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div x-data="{ tab: 'documents' }">
|
<div>
|
||||||
<div class="flex gap-1 border-b border-gray-800 mb-4">
|
<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">
|
<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)) })
|
Documents ({ fmt.Sprintf("%d", len(files)) })
|
||||||
|
|
@ -68,21 +65,28 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
||||||
<!-- Folder Tree -->
|
<!-- Folder Tree -->
|
||||||
<div class="bg-gray-900 rounded-lg border border-gray-800 p-3">
|
<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>
|
<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 {
|
for _, folder := range folders {
|
||||||
if folder.ParentID == "" {
|
if folder.ParentID == "" {
|
||||||
<div class="mb-1">
|
<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>
|
<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>
|
<span class="truncate">{ folder.Name }</span>
|
||||||
</div>
|
</a>
|
||||||
<!-- Child folders -->
|
<!-- Child folders -->
|
||||||
for _, child := range folders {
|
for _, child := range folders {
|
||||||
if child.ParentID == folder.ID {
|
if child.ParentID == folder.ID {
|
||||||
<div class="ml-4">
|
<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>
|
<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>
|
<span class="truncate">{ child.Name }</span>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,26 +97,47 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
||||||
|
|
||||||
<!-- Files Table -->
|
<!-- Files Table -->
|
||||||
<div class="col-span-3 bg-gray-900 rounded-lg border border-gray-800">
|
<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">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-800">
|
<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">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">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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-800/50">
|
<tbody id="fileTableBody" class="divide-y divide-gray-800/50">
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
<tr class="hover:bg-gray-800/30 transition">
|
if activeFolder == "" || activeFolder == "all" || file.FolderID == activeFolder {
|
||||||
<td class="px-4 py-2.5">
|
<tr class="hover:bg-gray-800/30 transition file-row" data-filename={ file.Name }>
|
||||||
<div class="flex items-center gap-2">
|
<td class="px-4 py-2.5">
|
||||||
@fileIcon(file.Name)
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm">{ file.Name }</span>
|
@fileIcon(file.Name)
|
||||||
</div>
|
<span class="text-sm">{ file.Name }</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileSize(file.FileSize) }</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">@FileStatusBadge(file.Status)</td>
|
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileSize(file.FileSize) }</td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</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-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';
|
: '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>
|
</script>
|
||||||
</div>
|
</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 {
|
func formatFileSize(bytes int64) string {
|
||||||
if bytes < 1024 {
|
if bytes < 1024 {
|
||||||
return fmt.Sprintf("%d B", bytes)
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
|
@ -185,6 +251,17 @@ func formatFileSize(bytes int64) string {
|
||||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
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) {
|
templ fileIcon(name string) {
|
||||||
<div class={ "w-7 h-7 rounded flex items-center justify-center text-xs font-semibold text-white",
|
<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")),
|
templ.KV("bg-red-500", hasSuffix(name, ".pdf")),
|
||||||
|
|
|
||||||
|
|
@ -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">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">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">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">IOI Date</th>
|
||||||
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">LOI Date</th>
|
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">LOI Date</th>
|
||||||
</tr>
|
</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">@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-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-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.IOIDate }</td>
|
||||||
<td class="px-4 py-3 text-xs text-gray-500">{ deal.LOIDate }</td>
|
<td class="px-4 py-3 text-xs text-gray-500">{ deal.LOIDate }</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue