feat: Misha requests 2026-02-24

- Dashboard: replace buckets with stage-based counts (Prospect/Internal/Initial Marketing/IOI/LOI)
- New room modal: industry typeahead, stage dropdown, email chip input for deal team
- New room flow: Add Users screen with permission groups (admin/contributor/viewer variants)
- New room flow: per-group folder visibility checkboxes
- New room flow: Folder Structure step (Excel import or manual build with saved templates)
- Deal room: edit deal info inline
- Deal room: drag-to-reorder folders + right-click up/down
- Deal room: context-aware folder pre-selection on upload
- Deal room: drag-and-drop file upload (modal + direct folder drop)
- Request list: upload with folder structure preview/choice
- Request list: auto-assign existing files to requests
- Request list: group targeting + multi-list support (replace/append/group-specific)
This commit is contained in:
James 2026-02-24 03:05:36 -05:00
parent 0540d5abee
commit 24f4702f06
12 changed files with 1071 additions and 127 deletions

View File

@ -0,0 +1,96 @@
# Misha's Feature Requests — 2026-02-24
## 1. Dashboard
### a. Overview Buckets (top)
- **Keep:** Active Rooms
- **Replace other buckets with:**
- Prospect
- Internal
- Initial Marketing
- IOI
- LOI
## 2. Creating a New Room (modal/popup)
### a. Industry Selection
- Pre-populated list of standard industries (user can scroll or type to filter)
- Ability to add custom/internal-to-firm industries as a new category in the dropdown
### b. Stage Categories
- Prospect
- Internal
- Initial Marketing
- IOI
- LOI
- Closed
### c. Folder Structure
- **Remove from this popup** — set up after initial dataroom is created
### d. Internal Deal Team (was "Initial Team")
- Rename field to "Internal Deal Team"
- Email tag input: user types email → becomes a tag/chip object → can type next email
- Auto-suggest contacts from the firm as user types (match against existing firm users)
- Click suggestion to add without typing full email
### e. Replace "Create Dataroom" button with "Add Users" button
- This button advances to the next screen (user permissions setup)
#### Add Users Screen:
1. **Create groups with roles:**
- Administrator — same permissions as creator
- Contributor — can upload documents
- Viewer (no downloads) — can only view with watermark, cannot download
- Viewer (downloads w/ watermark) — can view and download with watermark
- Viewer (downloads no watermark) — full view and download, no restrictions
2. **Folder/document visibility per group:**
- Checkbox tree next to each folder/document
- Creator selects which folders/docs each group can see
### f. After "Add Users" → "Folder Structure" button (bottom right has Back + Folder Structure)
#### Folder Structure Screen — Two Paths:
**Path 1: Upload Request List (Excel)**
- User uploads an Excel file
- System auto-generates folder structure from it
- User can edit the generated structure before finalizing
- System helps user fix/re-map if Excel format is not recognized correctly
**Path 2: Build Own Folder Structure**
- User manually creates folders
- Once built, user can **save the structure under a name** for reuse in future datarooms
- All folder structures are editable after dataroom creation (in the dataroom view)
## 3. Deal Rooms — Within Each Deal
### a. Document Side
1. **Edit deal info** — ability to modify the deal info that was entered during creation
2. **Drag-to-reorder folders** — grab and drag to change priority order; also right-click → move up/move down
3. **Context-aware folder pre-selection on upload:**
- If user clicked into a folder and then clicks "Upload Document", the folder selector should pre-select that folder
- User can still change it manually
4. **Drag-and-drop file upload:**
- Upload modal should support drag-and-drop in addition to file picker
- Clicking into a folder: if user has upload permission, they can drag-and-drop files directly into the folder view (no need to click "Upload Document" button)
### b. Request List Side
1. **Upload a request/diligence list:**
- User gets choice: convert folder structure to match the request list, OR keep original folder structure
- Show a preview of what the new folder structure would look like before user decides
2. **Auto-assign existing files to requests:**
- If files already exist in the dataroom when a request list is uploaded, offer to auto-assign existing files to matching request line items
- User reviews and corrects assignments before confirming
3. **Request list targeting:**
- When uploading, user selects: all groups OR a specific group
- Support multiple request list uploads:
- Options: replace existing list, add to existing list, or create a group-specific list
- One main request list for all groups + separate group-specific lists can coexist

View File

@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS deals (
name TEXT NOT NULL,
description TEXT DEFAULT '',
target_company TEXT DEFAULT '',
stage TEXT NOT NULL DEFAULT 'pipeline' CHECK (stage IN ('pipeline','loi','initial_review','due_diligence','final_negotiation','closed','dead')),
stage TEXT NOT NULL DEFAULT 'prospect' CHECK (stage IN ('prospect','internal','initial_marketing','ioi','loi','closed','pipeline','initial_review','due_diligence','final_negotiation','dead')),
deal_size REAL DEFAULT 0,
currency TEXT DEFAULT 'USD',
ioi_date TEXT DEFAULT '',
@ -271,10 +271,10 @@ func seed(db *sql.DB) error {
// Deals
`INSERT INTO deals (id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by) VALUES
('deal-1', 'org-1', 'Project Aurora', 'AI-powered enterprise SaaS platform acquisition', 'TechNova Solutions', 'due_diligence', 45000000, 'USD', '2025-12-15', '2026-01-10', '2026-03-15', '2026-04-30', 72, 'admin-misha'),
('deal-2', 'org-1', 'Project Beacon', 'Healthcare technology platform investment', 'MedFlow Health', 'initial_review', 28000000, 'USD', '2026-01-05', '', '', '2026-06-15', 45, 'admin-misha'),
('deal-1', 'org-1', 'Project Aurora', 'AI-powered enterprise SaaS platform acquisition', 'TechNova Solutions', 'ioi', 45000000, 'USD', '2025-12-15', '2026-01-10', '2026-03-15', '2026-04-30', 72, 'admin-misha'),
('deal-2', 'org-1', 'Project Beacon', 'Healthcare technology platform investment', 'MedFlow Health', 'initial_marketing', 28000000, 'USD', '2026-01-05', '', '', '2026-06-15', 45, 'admin-misha'),
('deal-3', 'org-1', 'Project Cascade', 'Fintech payment processing acquisition', 'PayStream Inc', 'loi', 62000000, 'USD', '2025-11-20', '2026-02-01', '2026-04-01', '2026-05-15', 58, 'admin-misha'),
('deal-4', 'org-1', 'Project Delta', 'Industrial IoT platform strategic investment', 'SensorGrid Corp', 'pipeline', 15000000, 'USD', '', '', '', '2026-08-01', 30, 'admin-misha')`,
('deal-4', 'org-1', 'Project Delta', 'Industrial IoT platform strategic investment', 'SensorGrid Corp', 'prospect', 15000000, 'USD', '', '', '', '2026-08-01', 30, 'admin-misha')`,
// Folders for deal-1 (Project Aurora)
`INSERT INTO folders (id, deal_id, parent_id, name, description) VALUES

View File

@ -223,7 +223,7 @@ func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) {
currency = "USD"
}
if stage == "" {
stage = "pipeline"
stage = "prospect"
}
if id == "" {

View File

@ -85,8 +85,8 @@ func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) {
}
var deal model.Deal
err := h.db.QueryRow(`SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by FROM deals WHERE id = ?`, dealID).Scan(
&deal.ID, &deal.OrganizationID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.CreatedBy)
err := h.db.QueryRow(`SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by, COALESCE(industry, '') FROM deals WHERE id = ?`, dealID).Scan(
&deal.ID, &deal.OrganizationID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.CreatedBy, &deal.Industry)
if err != nil {
http.NotFound(w, r)
return
@ -117,7 +117,7 @@ func (h *Handler) handleCreateDeal(w http.ResponseWriter, r *http.Request) {
targetCompany := strings.TrimSpace(r.FormValue("target_company"))
stage := r.FormValue("stage")
if stage == "" {
stage = "pipeline"
stage = "prospect"
}
dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64)
currency := r.FormValue("currency")
@ -502,6 +502,136 @@ func (h *Handler) getActivitiesFiltered(orgID string, dealID string, limit int)
return h.getActivitiesFilteredBuyer(orgID, dealID, "", limit)
}
func (h *Handler) handleContactSearch(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
q := r.URL.Query().Get("q")
if q == "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
return
}
pattern := "%" + q + "%"
rows, err := h.db.Query(`SELECT DISTINCT email, full_name FROM profiles WHERE organization_id = ? AND (email LIKE ? OR full_name LIKE ?) LIMIT 10`, profile.OrganizationID, pattern, pattern)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
return
}
defer rows.Close()
type contact struct {
Email string `json:"email"`
Name string `json:"name"`
}
var contacts []contact
for rows.Next() {
var c contact
rows.Scan(&c.Email, &c.Name)
contacts = append(contacts, c)
}
// Also search contacts table
rows2, _ := h.db.Query(`SELECT DISTINCT email, full_name FROM contacts WHERE organization_id = ? AND contact_type = 'internal' AND (email LIKE ? OR full_name LIKE ?) LIMIT 10`, profile.OrganizationID, pattern, pattern)
if rows2 != nil {
for rows2.Next() {
var c contact
rows2.Scan(&c.Email, &c.Name)
contacts = append(contacts, c)
}
rows2.Close()
}
if contacts == nil {
contacts = []contact{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(contacts)
}
func (h *Handler) handleIndustryList(w http.ResponseWriter, r *http.Request) {
// Standard pre-populated industries
industries := []string{
"Aerospace & Defense",
"Agriculture",
"Automotive",
"Business Services",
"Chemicals",
"Consumer Products",
"Education",
"Energy",
"Financial Services",
"Food & Beverage",
"Healthcare",
"Hospitality & Leisure",
"Industrial Manufacturing",
"Insurance",
"Internet & E-Commerce",
"Logistics & Transportation",
"Media & Entertainment",
"Mining & Metals",
"Pharmaceuticals",
"Real Estate",
"Retail",
"Software & Technology",
"Telecommunications",
"Utilities",
}
// Fetch custom industries from existing deals
rows, err := h.db.Query(`SELECT DISTINCT industry FROM deals WHERE organization_id = ? AND industry != '' ORDER BY industry`, getProfile(r.Context()).OrganizationID)
if err == nil {
defer rows.Close()
existing := make(map[string]bool)
for _, ind := range industries {
existing[ind] = true
}
for rows.Next() {
var ind string
rows.Scan(&ind)
if !existing[ind] {
industries = append(industries, ind)
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(industries)
}
func (h *Handler) handleDealUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
r.ParseForm()
dealID := r.FormValue("deal_id")
if dealID == "" {
http.Error(w, "Missing deal ID", 400)
return
}
name := strings.TrimSpace(r.FormValue("name"))
targetCompany := strings.TrimSpace(r.FormValue("target_company"))
stage := r.FormValue("stage")
dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64)
currency := r.FormValue("currency")
ioiDate := r.FormValue("ioi_date")
loiDate := r.FormValue("loi_date")
exclusivityEnd := r.FormValue("exclusivity_end")
industry := strings.TrimSpace(r.FormValue("industry"))
description := strings.TrimSpace(r.FormValue("description"))
_, err := h.db.Exec(`UPDATE deals SET name=?, description=?, target_company=?, stage=?, deal_size=?, currency=?, ioi_date=?, loi_date=?, exclusivity_end_date=?, industry=?, updated_at=datetime('now') WHERE id=?`,
name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, industry, dealID)
if err != nil {
http.Error(w, fmt.Sprintf("Error updating deal: %v", err), 500)
return
}
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) getActivitiesFilteredBuyer(orgID string, dealID string, buyerGroup 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')

View File

@ -91,6 +91,16 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/deals/search/", h.requireAuth(h.handleDealSearch))
mux.HandleFunc("/analytics/buyers", h.requireAuth(h.handleAnalyticsBuyers))
// Deal update
mux.HandleFunc("/deals/update", h.requireAuth(h.handleDealUpdate))
// API endpoints
mux.HandleFunc("/api/contacts/search", h.requireAuth(h.handleContactSearch))
mux.HandleFunc("/api/industries", h.requireAuth(h.handleIndustryList))
// Request list upload
mux.HandleFunc("/deals/requests/upload", h.requireAuth(h.handleRequestListUpload))
// HTMX partials
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))
}

View File

@ -1,7 +1,12 @@
package handler
import (
"bufio"
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"dealroom/internal/rbac"
"dealroom/templates"
@ -46,6 +51,188 @@ func (h *Handler) handleRequestList(w http.ResponseWriter, r *http.Request) {
templates.RequestListPage(profile, deals, dealRequests).Render(r.Context(), w)
}
func (h *Handler) handleRequestListUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
profile := getProfile(r.Context())
err := r.ParseMultipartForm(10 << 20) // 10MB
if err != nil {
http.Error(w, "Error parsing form", 400)
return
}
dealID := r.FormValue("deal_id")
targetGroup := r.FormValue("target_group") // "all" or specific group name
uploadMode := r.FormValue("upload_mode") // "replace", "add", "group_specific"
convertFolders := r.FormValue("convert_folders") // "yes" or "no"
if dealID == "" {
http.Error(w, "Deal ID required", 400)
return
}
file, _, err := r.FormFile("request_list")
if err != nil {
http.Error(w, "File is required", 400)
return
}
defer file.Close()
// Parse CSV (supports basic CSV format: section, item_number, description, priority)
reader := csv.NewReader(bufio.NewReader(file))
reader.FieldsPerRecord = -1 // variable fields
reader.TrimLeadingSpace = true
var items []struct {
section, itemNumber, description, priority string
}
lineNum := 0
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
lineNum++
if lineNum == 1 {
// skip header row
continue
}
if len(record) < 3 {
continue
}
item := struct {
section, itemNumber, description, priority string
}{
section: strings.TrimSpace(record[0]),
itemNumber: strings.TrimSpace(record[1]),
description: strings.TrimSpace(record[2]),
priority: "medium",
}
if len(record) >= 4 {
p := strings.ToLower(strings.TrimSpace(record[3]))
if p == "high" || p == "medium" || p == "low" {
item.priority = p
}
}
items = append(items, item)
}
if len(items) == 0 {
http.Error(w, "No valid items found in CSV", 400)
return
}
// Handle upload mode
if uploadMode == "replace" {
if targetGroup == "all" || targetGroup == "" {
h.db.Exec("DELETE FROM diligence_requests WHERE deal_id = ?", dealID)
} else {
h.db.Exec("DELETE FROM diligence_requests WHERE deal_id = ? AND buyer_group = ?", dealID, targetGroup)
}
}
buyerGroup := ""
if targetGroup != "all" && targetGroup != "" {
buyerGroup = targetGroup
}
isBuyerSpecific := 0
if uploadMode == "group_specific" && buyerGroup != "" {
isBuyerSpecific = 1
}
// Insert request items
for _, item := range items {
id := generateID("req")
h.db.Exec(`INSERT INTO diligence_requests (id, deal_id, item_number, section, description, priority, buyer_group, is_buyer_specific, visible_to_buyer_group, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, dealID, item.itemNumber, item.section, item.description, item.priority, buyerGroup, isBuyerSpecific, buyerGroup, profile.ID)
}
// Optionally convert folder structure
if convertFolders == "yes" {
// Create folders from unique sections
sections := make(map[string]bool)
for _, item := range items {
sections[item.section] = true
}
for section := range sections {
var existing int
h.db.QueryRow("SELECT COUNT(*) FROM folders WHERE deal_id = ? AND name = ?", dealID, section).Scan(&existing)
if existing == 0 {
folderID := generateID("folder")
h.db.Exec("INSERT INTO folders (id, deal_id, parent_id, name, created_by) VALUES (?, ?, '', ?, ?)",
folderID, dealID, section, profile.ID)
}
}
}
// Auto-assign existing files to matching requests
h.autoAssignFilesToRequests(dealID)
h.logActivity(dealID, profile.ID, profile.OrganizationID, "upload", "request_list", fmt.Sprintf("%d items", len(items)), "")
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) autoAssignFilesToRequests(dealID string) {
// Get all unlinked requests
rows, err := h.db.Query("SELECT id, description, section FROM diligence_requests WHERE deal_id = ? AND (linked_file_ids = '' OR linked_file_ids IS NULL)", dealID)
if err != nil {
return
}
defer rows.Close()
type reqInfo struct {
id, description, section string
}
var reqs []reqInfo
for rows.Next() {
var r reqInfo
rows.Scan(&r.id, &r.description, &r.section)
reqs = append(reqs, r)
}
// Get all files
files, err := h.db.Query("SELECT id, name FROM files WHERE deal_id = ?", dealID)
if err != nil {
return
}
defer files.Close()
type fileInfo struct {
id, name string
}
var fileList []fileInfo
for files.Next() {
var f fileInfo
files.Scan(&f.id, &f.name)
fileList = append(fileList, f)
}
// Simple keyword matching
for _, req := range reqs {
words := strings.Fields(strings.ToLower(req.description))
for _, f := range fileList {
fname := strings.ToLower(f.name)
matchCount := 0
for _, w := range words {
if len(w) > 3 && strings.Contains(fname, w) {
matchCount++
}
}
if matchCount >= 2 {
h.db.Exec("UPDATE diligence_requests SET linked_file_ids = ? WHERE id = ?", f.id, req.id)
break
}
}
}
}
func (h *Handler) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

View File

@ -172,18 +172,27 @@ type Invite struct {
// StageName returns human-readable stage name
func StageName(stage string) string {
switch stage {
case "pipeline":
return "Pipeline"
case "prospect":
return "Prospect"
case "internal":
return "Internal"
case "initial_marketing":
return "Initial Marketing"
case "ioi":
return "IOI"
case "loi":
return "LOI Stage"
case "initial_review":
return "Initial Review"
case "due_diligence":
return "Due Diligence"
case "final_negotiation":
return "Final Negotiation"
return "LOI"
case "closed":
return "Closed"
// Legacy stages (backward compat)
case "pipeline":
return "Prospect"
case "initial_review":
return "Initial Marketing"
case "due_diligence":
return "IOI"
case "final_negotiation":
return "LOI"
case "dead":
return "Dead"
default:

View File

@ -252,13 +252,12 @@ templ AdminDealForm(profile *model.Profile, deal *model.Deal) {
@formField("target_company", "Target Company", "text", deal.TargetCompany, false)
@formField("industry", "Industry", "text", deal.Industry, false)
@formSelect("stage", "Stage", deal.Stage, []SelectOption{
{Value: "pipeline", Label: "Pipeline"},
{Value: "loi", Label: "LOI Stage"},
{Value: "initial_review", Label: "Initial Review"},
{Value: "due_diligence", Label: "Due Diligence"},
{Value: "final_negotiation", Label: "Final Negotiation"},
{Value: "prospect", Label: "Prospect"},
{Value: "internal", Label: "Internal"},
{Value: "initial_marketing", Label: "Initial Marketing"},
{Value: "ioi", Label: "IOI"},
{Value: "loi", Label: "LOI"},
{Value: "closed", Label: "Closed"},
{Value: "dead", Label: "Dead"},
})
<div class="grid grid-cols-2 gap-4">
@formField("deal_size", "Deal Size", "number", fmt.Sprintf("%.0f", deal.DealSize), false)

View File

@ -4,11 +4,11 @@ import "dealroom/internal/model"
templ StageBadge(stage string) {
<span class={ "text-xs px-1.5 py-0.5 rounded-full font-medium",
templ.KV("bg-gray-700 text-gray-300", stage == "pipeline"),
templ.KV("bg-green-500/10 text-green-400", stage == "loi"),
templ.KV("bg-teal-500/10 text-teal-400", stage == "initial_review"),
templ.KV("bg-amber-500/10 text-amber-400", stage == "due_diligence"),
templ.KV("bg-emerald-500/10 text-emerald-400", stage == "final_negotiation"),
templ.KV("bg-gray-700 text-gray-300", stage == "prospect" || stage == "pipeline"),
templ.KV("bg-blue-500/10 text-blue-400", stage == "internal"),
templ.KV("bg-teal-500/10 text-teal-400", stage == "initial_marketing" || stage == "initial_review"),
templ.KV("bg-amber-500/10 text-amber-400", stage == "ioi" || stage == "due_diligence"),
templ.KV("bg-green-500/10 text-green-400", stage == "loi" || stage == "final_negotiation"),
templ.KV("bg-emerald-500/20 text-emerald-400", stage == "closed"),
templ.KV("bg-red-500/10 text-red-400", stage == "dead") }>
{ model.StageName(stage) }

View File

@ -20,11 +20,13 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
</div>
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
<div class="grid grid-cols-6 gap-3">
@statCard("ACTIVE ROOMS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d total", len(deals)), "folder")
@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")
@statCard("PROSPECT", fmt.Sprintf("%d", countByStages(deals, "prospect", "pipeline")), "prospecting", "file")
@statCard("INTERNAL", fmt.Sprintf("%d", countByStages(deals, "internal", "")), "internal review", "users")
@statCard("INITIAL MARKETING", fmt.Sprintf("%d", countByStages(deals, "initial_marketing", "initial_review")), "marketing", "trend")
@statCard("IOI", fmt.Sprintf("%d", countByStages(deals, "ioi", "due_diligence")), "indication of interest", "trend")
@statCard("LOI", fmt.Sprintf("%d", countByStages(deals, "loi", "final_negotiation")), "letter of intent", "trend")
</div>
<!-- Content Grid -->
@ -99,7 +101,7 @@ templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model
<!-- 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="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-xl 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">
@ -132,10 +134,10 @@ func countByStage(deals []*model.Deal, stage string) int {
return count
}
func countIOIStage(deals []*model.Deal) int {
func countByStages(deals []*model.Deal, stage1 string, stage2 string) int {
count := 0
for _, d := range deals {
if d.Stage == "loi" || d.Stage == "initial_review" {
if d.Stage == stage1 || (stage2 != "" && d.Stage == stage2) {
count++
}
}
@ -182,77 +184,298 @@ templ statCard(label, value, subtitle, iconType string) {
}
templ newRoomForm() {
<form action="/deals/create" method="POST" class="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<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 class="grid grid-cols-2 gap-3">
<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>
<form id="newRoomFormEl" action="/deals/create" method="POST" class="max-h-[75vh] overflow-y-auto pr-1">
<!-- Step Indicator -->
<div class="flex items-center gap-2 mb-5">
<div id="stepIndicator1" class="flex items-center gap-1.5">
<div class="w-6 h-6 rounded-full bg-teal-500 text-white text-xs flex items-center justify-center font-bold">1</div>
<span class="text-xs font-medium text-teal-400">Deal Info</span>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Industry</label>
<input type="text" name="industry" placeholder="e.g. Healthcare, Fintech" 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 class="w-8 h-px bg-gray-700"></div>
<div id="stepIndicator2" class="flex items-center gap-1.5">
<div class="w-6 h-6 rounded-full bg-gray-700 text-gray-400 text-xs flex items-center justify-center font-bold">2</div>
<span class="text-xs text-gray-500">Add Users</span>
</div>
<div class="w-8 h-px bg-gray-700"></div>
<div id="stepIndicator3" class="flex items-center gap-1.5">
<div class="w-6 h-6 rounded-full bg-gray-700 text-gray-400 text-xs flex items-center justify-center font-bold">3</div>
<span class="text-xs text-gray-500">Folders</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<!-- Step 1: Deal Info -->
<div id="step1" class="space-y-4">
<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"/>
<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">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>
<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>
<div class="grid grid-cols-3 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 class="grid grid-cols-2 gap-3">
<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="prospect">Prospect</option>
<option value="internal">Internal</option>
<option value="initial_marketing">Initial Marketing</option>
<option value="ioi">IOI</option>
<option value="loi">LOI</option>
<option value="closed">Closed</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Industry</label>
<div class="relative">
<input type="text" name="industry" id="industryInput" placeholder="Type to search or add..." autocomplete="off"
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 id="industrySuggestions" class="hidden absolute z-20 mt-1 w-full bg-gray-800 border border-gray-700 rounded-lg shadow-xl max-h-48 overflow-y-auto"></div>
</div>
</div>
</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>
<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"/>
<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>
<label class="block text-xs font-medium text-gray-400 mb-1">Exclusivity End</label>
<input type="date" name="exclusivity_end" 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"/>
<label class="block text-xs font-medium text-gray-400 mb-1">Internal Deal Team <span class="text-gray-600">(type email and press Enter)</span></label>
<div id="teamTags" class="flex flex-wrap gap-1.5 p-2 bg-gray-800 border border-gray-700 rounded-lg min-h-[42px] cursor-text" onclick="document.getElementById('teamInput').focus()">
<input type="text" id="teamInput" placeholder="team@company.com" autocomplete="off"
class="flex-1 min-w-[150px] bg-transparent border-none text-sm text-gray-100 focus:outline-none placeholder-gray-600"/>
</div>
<input type="hidden" name="invite_emails" id="inviteEmailsHidden"/>
<div id="teamSuggestions" class="hidden relative z-20 mt-1 w-full bg-gray-800 border border-gray-700 rounded-lg shadow-xl max-h-36 overflow-y-auto"></div>
</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="button" onclick="goToStep(2)" class="px-4 py-2 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Add Users</button>
</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>
<!-- Step 2: Add Users (Groups & Roles) -->
<div id="step2" class="space-y-4 hidden">
<p class="text-sm text-gray-400 mb-2">Create groups and assign permissions. Each group gets a specific role.</p>
<div id="userGroups" class="space-y-3">
<div class="p-3 bg-gray-800/50 border border-gray-700 rounded-lg">
<div class="flex items-center justify-between mb-2">
<input type="text" name="group_name_0" value="Administrators" placeholder="Group name" class="bg-transparent text-sm font-medium text-gray-100 focus:outline-none border-b border-transparent focus:border-teal-500"/>
<select name="group_role_0" class="px-2 py-1 bg-gray-700 border border-gray-600 rounded text-xs text-gray-100 focus:outline-none">
<option value="administrator">Administrator</option>
<option value="contributor">Contributor</option>
<option value="viewer_no_download">Viewer (no downloads)</option>
<option value="viewer_watermark">Viewer (downloads w/ watermark)</option>
<option value="viewer_full">Viewer (downloads, no watermark)</option>
</select>
</div>
<input type="text" name="group_emails_0" placeholder="Add emails separated by commas..." class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
</div>
<button type="button" onclick="addUserGroup()" class="flex items-center gap-1.5 text-xs text-teal-400 hover:text-teal-300 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 Group
</button>
<div class="flex justify-between pt-2">
<button type="button" onclick="goToStep(1)" 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">Back</button>
<button type="button" onclick="goToStep(3)" class="px-4 py-2 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Folder Structure</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Folder Structure <span class="text-gray-600">(one path per line, e.g. Financial/Q4 Reports)</span></label>
<textarea name="folder_structure" rows="3" placeholder="Financial Documents&#10;Legal Documents&#10;Technical DD/Architecture&#10;Technical DD/Security" 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 font-mono"></textarea>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Initial Team <span class="text-gray-600">(one email per line)</span></label>
<textarea name="invite_emails" rows="2" placeholder="analyst@company.com&#10;associate@company.com" 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 font-mono"></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>
<!-- Step 3: Folder Structure -->
<div id="step3" class="space-y-4 hidden">
<p class="text-sm text-gray-400 mb-2">Choose how to set up your folder structure.</p>
<div class="grid grid-cols-2 gap-3">
<button type="button" onclick="selectFolderPath('manual')" id="btnManual" class="p-4 border-2 border-teal-500 rounded-lg text-left hover:bg-gray-800/50 transition">
<div class="text-sm font-medium text-teal-400 mb-1">Build Manually</div>
<p class="text-xs text-gray-500">Create your own folder structure</p>
</button>
<button type="button" onclick="selectFolderPath('upload')" id="btnUpload" class="p-4 border-2 border-gray-700 rounded-lg text-left hover:bg-gray-800/50 transition">
<div class="text-sm font-medium text-gray-300 mb-1">Upload Request List</div>
<p class="text-xs text-gray-500">Auto-generate from Excel file</p>
</button>
</div>
<!-- Manual path -->
<div id="folderManual">
<label class="block text-xs font-medium text-gray-400 mb-1">Folder Structure <span class="text-gray-600">(one path per line)</span></label>
<textarea name="folder_structure" rows="4" placeholder="Financial Documents&#10;Legal Documents&#10;Technical DD/Architecture&#10;Technical DD/Security" 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 font-mono"></textarea>
<div class="mt-2">
<label class="block text-xs font-medium text-gray-400 mb-1">Save as template <span class="text-gray-600">(optional)</span></label>
<input type="text" name="template_name" placeholder="e.g. Standard M&A" 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>
<!-- Upload path (hidden by default) -->
<div id="folderUpload" class="hidden">
<label class="block text-xs font-medium text-gray-400 mb-1">Upload Excel Request List</label>
<div class="border-2 border-dashed border-gray-700 rounded-lg p-6 text-center hover:border-teal-500/50 transition cursor-pointer" onclick="document.getElementById('excelUploadInput').click()">
<svg class="w-8 h-8 text-gray-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p class="text-sm text-gray-400">Click to upload or drag and drop</p>
<p class="text-xs text-gray-600 mt-1">Excel files (.xlsx, .xls, .csv)</p>
</div>
<input type="file" id="excelUploadInput" name="request_list_file" accept=".xlsx,.xls,.csv" class="hidden"/>
<div id="excelFileName" class="mt-2 text-xs text-teal-400 hidden"></div>
</div>
<div class="flex justify-between pt-2">
<button type="button" onclick="goToStep(2)" 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">Back</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>
</div>
</form>
<script>
// Multi-step navigation
var currentStep = 1;
function goToStep(step) {
if (step === 2) {
var nameInput = document.querySelector('#step1 input[name="name"]');
if (!nameInput.value.trim()) { nameInput.focus(); return; }
}
document.getElementById('step1').classList.toggle('hidden', step !== 1);
document.getElementById('step2').classList.toggle('hidden', step !== 2);
document.getElementById('step3').classList.toggle('hidden', step !== 3);
for (var i = 1; i <= 3; i++) {
var el = document.getElementById('stepIndicator' + i);
var circle = el.querySelector('div');
var text = el.querySelector('span');
if (i <= step) {
circle.className = 'w-6 h-6 rounded-full bg-teal-500 text-white text-xs flex items-center justify-center font-bold';
text.className = 'text-xs font-medium text-teal-400';
} else {
circle.className = 'w-6 h-6 rounded-full bg-gray-700 text-gray-400 text-xs flex items-center justify-center font-bold';
text.className = 'text-xs text-gray-500';
}
}
currentStep = step;
}
// User groups
var groupCount = 1;
function addUserGroup() {
var html = '<div class="p-3 bg-gray-800/50 border border-gray-700 rounded-lg">' +
'<div class="flex items-center justify-between mb-2">' +
'<input type="text" name="group_name_' + groupCount + '" placeholder="Group name" class="bg-transparent text-sm font-medium text-gray-100 focus:outline-none border-b border-transparent focus:border-teal-500"/>' +
'<select name="group_role_' + groupCount + '" class="px-2 py-1 bg-gray-700 border border-gray-600 rounded text-xs text-gray-100 focus:outline-none">' +
'<option value="administrator">Administrator</option>' +
'<option value="contributor">Contributor</option>' +
'<option value="viewer_no_download">Viewer (no downloads)</option>' +
'<option value="viewer_watermark">Viewer (downloads w/ watermark)</option>' +
'<option value="viewer_full">Viewer (downloads, no watermark)</option>' +
'</select></div>' +
'<input type="text" name="group_emails_' + groupCount + '" placeholder="Add emails separated by commas..." class="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-100 focus:border-teal-500 focus:outline-none"/>' +
'</div>';
document.getElementById('userGroups').insertAdjacentHTML('beforeend', html);
groupCount++;
}
// Folder path selection
function selectFolderPath(path) {
document.getElementById('folderManual').classList.toggle('hidden', path !== 'manual');
document.getElementById('folderUpload').classList.toggle('hidden', path !== 'upload');
document.getElementById('btnManual').className = path === 'manual'
? 'p-4 border-2 border-teal-500 rounded-lg text-left hover:bg-gray-800/50 transition'
: 'p-4 border-2 border-gray-700 rounded-lg text-left hover:bg-gray-800/50 transition';
document.getElementById('btnUpload').className = path === 'upload'
? 'p-4 border-2 border-teal-500 rounded-lg text-left hover:bg-gray-800/50 transition'
: 'p-4 border-2 border-gray-700 rounded-lg text-left hover:bg-gray-800/50 transition';
}
document.getElementById('excelUploadInput').addEventListener('change', function(e) {
if (e.target.files.length > 0) {
var el = document.getElementById('excelFileName');
el.textContent = 'Selected: ' + e.target.files[0].name;
el.classList.remove('hidden');
}
});
// Industry autocomplete
var industries = [];
fetch('/api/industries').then(function(r) { return r.json(); }).then(function(data) { industries = data || []; });
var industryInput = document.getElementById('industryInput');
var industrySuggestions = document.getElementById('industrySuggestions');
industryInput.addEventListener('input', function() {
var val = this.value.toLowerCase();
if (!val) { industrySuggestions.classList.add('hidden'); return; }
var matches = industries.filter(function(i) { return i.toLowerCase().includes(val); });
if (matches.length === 0) {
industrySuggestions.innerHTML = '<div class="px-3 py-2 text-xs text-gray-500 italic">Press Enter to add &quot;' + this.value + '&quot; as custom industry</div>';
} else {
industrySuggestions.innerHTML = matches.map(function(m) {
return '<button type="button" class="block w-full text-left px-3 py-1.5 text-sm text-gray-200 hover:bg-gray-700 transition" onclick="selectIndustry(\'' + m.replace(/'/g, "\\'") + '\')">' + m + '</button>';
}).join('');
}
industrySuggestions.classList.remove('hidden');
});
industryInput.addEventListener('blur', function() { setTimeout(function() { industrySuggestions.classList.add('hidden'); }, 200); });
function selectIndustry(val) { industryInput.value = val; industrySuggestions.classList.add('hidden'); }
// Internal Deal Team: tag/chip input with auto-suggest
var teamEmails = [];
var teamInput = document.getElementById('teamInput');
var teamTags = document.getElementById('teamTags');
var teamSuggestions = document.getElementById('teamSuggestions');
function addTeamEmail(email) {
email = email.trim();
if (!email || teamEmails.includes(email)) return;
teamEmails.push(email);
var tag = document.createElement('span');
tag.className = 'inline-flex items-center gap-1 px-2 py-0.5 bg-teal-500/10 text-teal-400 text-xs rounded-full';
tag.innerHTML = email + '<button type="button" class="hover:text-red-400 ml-0.5" onclick="removeTeamEmail(this, \'' + email + '\')">&times;</button>';
teamTags.insertBefore(tag, teamInput);
document.getElementById('inviteEmailsHidden').value = teamEmails.join('\n');
teamInput.value = '';
teamSuggestions.classList.add('hidden');
}
function removeTeamEmail(btn, email) {
teamEmails = teamEmails.filter(function(e) { return e !== email; });
btn.parentElement.remove();
document.getElementById('inviteEmailsHidden').value = teamEmails.join('\n');
}
teamInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
e.preventDefault();
var val = this.value.replace(/,/g, '').trim();
if (val) addTeamEmail(val);
}
if (e.key === 'Backspace' && !this.value && teamEmails.length > 0) {
removeTeamEmail(teamTags.querySelector('span:last-of-type button'), teamEmails[teamEmails.length - 1]);
}
});
var searchTimeout;
teamInput.addEventListener('input', function() {
var val = this.value.trim();
clearTimeout(searchTimeout);
if (val.length < 2) { teamSuggestions.classList.add('hidden'); return; }
searchTimeout = setTimeout(function() {
fetch('/api/contacts/search?q=' + encodeURIComponent(val))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data || data.length === 0) { teamSuggestions.classList.add('hidden'); return; }
teamSuggestions.innerHTML = data.map(function(c) {
return '<button type="button" class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-700 transition" onclick="addTeamEmail(\'' + c.email + '\')">' +
'<span class="text-gray-200">' + c.name + '</span> <span class="text-gray-500 text-xs">' + c.email + '</span></button>';
}).join('');
teamSuggestions.classList.remove('hidden');
});
}, 200);
});
teamInput.addEventListener('blur', function() { setTimeout(function() { teamSuggestions.classList.add('hidden'); }, 200); });
</script>
}
templ activityIcon(actType string) {

View File

@ -17,6 +17,11 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">{ deal.Name }</h1>
@StageBadge(deal.Stage)
if profile.Role == "owner" || profile.Role == "admin" {
<button onclick="document.getElementById('editDealModal').classList.remove('hidden')" class="text-xs text-gray-500 hover:text-teal-400 transition ml-1" title="Edit deal info">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</button>
}
</div>
<p class="text-sm text-gray-500 mt-1">{ deal.TargetCompany } · { deal.Description }</p>
</div>
@ -84,27 +89,32 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<span class="truncate">All Documents</span>
</a>
</div>
for _, folder := range folders {
if folder.ParentID == "" {
<div class="mb-1">
<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">{ folder.Name }</span>
</a>
<!-- Child folders -->
for _, child := range folders {
if child.ParentID == folder.ID {
<div class="ml-4">
<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">{ child.Name }</span>
</a>
</div>
<div id="folderList">
for _, folder := range folders {
if folder.ParentID == "" {
<div class="mb-1 folder-item" draggable="true" data-folder-id={ folder.ID } data-deal-id={ deal.ID }
ondragstart="handleFolderDragStart(event)" ondragover="handleFolderDragOver(event)" ondrop="handleFolderDrop(event)"
oncontextmenu={ templ.ComponentScript{Call: fmt.Sprintf("showFolderContextMenu(event, '%s', '%s')", folder.ID, deal.ID)} }>
<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-3 h-3 text-gray-600 cursor-grab shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></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">{ folder.Name }</span>
</a>
<!-- Child folders -->
for _, child := range folders {
if child.ParentID == folder.ID {
<div class="ml-4">
<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">{ child.Name }</span>
</a>
</div>
}
}
}
</div>
</div>
}
}
}
</div>
if profile.Role != "buyer" {
<div class="mt-4 pt-3 border-t border-gray-800">
<button onclick="document.getElementById('newFolderModal').classList.remove('hidden')" class="w-full flex items-center justify-center gap-1.5 py-1.5 px-2 rounded text-sm text-gray-400 hover:text-teal-400 hover:bg-gray-800 transition-colors">
@ -116,15 +126,21 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
<!-- Files Table -->
<div class="col-span-3 bg-gray-900 rounded-lg border border-gray-800">
<div id="filesPanel" class="col-span-3 bg-gray-900 rounded-lg border border-gray-800"
ondragover="handleFileDragOver(event)" ondrop="handleFileDrop(event)" ondragleave="handleFileDragLeave(event)">
<!-- Toolbar -->
<div class="p-3 border-b border-gray-800 flex gap-3">
<input type="text" id="fileSearch" placeholder="Search files..." oninput="filterFiles()" class="flex-1 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"/>
<button onclick="document.getElementById('uploadModal').classList.remove('hidden')" class="px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5">
<button onclick="openUploadModal()" class="px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
Upload Document
</button>
</div>
<!-- Drop zone overlay -->
<div id="dropOverlay" class="hidden p-8 text-center border-2 border-dashed border-teal-500/50 m-2 rounded-lg bg-teal-500/5">
<svg class="w-10 h-10 text-teal-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p class="text-sm text-teal-400 font-medium">Drop files here to upload</p>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
@ -198,6 +214,15 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<!-- Requests Tab -->
<div id="panel-requests" style="display:none">
<!-- Upload Request List button -->
if profile.Role == "owner" || profile.Role == "admin" {
<div class="mb-4 flex justify-end">
<button onclick="document.getElementById('uploadRequestListModal').classList.remove('hidden')" class="px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
Upload Request List
</button>
</div>
}
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
@ -223,8 +248,8 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<td class="px-4 py-2.5">@PriorityBadge(req.Priority)</td>
<td class="px-4 py-2.5">@StatusIcon(req.AtlasStatus)</td>
<td class="px-4 py-2.5 text-xs text-gray-400">
if req.AtlasStatus == "Complete" {
<span class="text-teal-400/80">Found in Q3 Financials.pdf</span>
if req.AtlasNote != "" {
{ req.AtlasNote }
} else {
<span class="text-gray-600 italic">No notes</span>
}
@ -255,23 +280,105 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
</div>
<!-- Edit Deal Modal -->
<div id="editDealModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('editDealModal').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">Edit Deal Info</h2>
<button onclick="document.getElementById('editDealModal').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/update" method="POST" class="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<input type="hidden" name="deal_id" value={ deal.ID }/>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Project Name</label>
<input type="text" name="name" value={ deal.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" value={ deal.TargetCompany } 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 class="grid grid-cols-2 gap-3">
<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="prospect" selected?={ deal.Stage == "prospect" || deal.Stage == "pipeline" }>Prospect</option>
<option value="internal" selected?={ deal.Stage == "internal" }>Internal</option>
<option value="initial_marketing" selected?={ deal.Stage == "initial_marketing" || deal.Stage == "initial_review" }>Initial Marketing</option>
<option value="ioi" selected?={ deal.Stage == "ioi" || deal.Stage == "due_diligence" }>IOI</option>
<option value="loi" selected?={ deal.Stage == "loi" || deal.Stage == "final_negotiation" }>LOI</option>
<option value="closed" selected?={ deal.Stage == "closed" }>Closed</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Industry</label>
<input type="text" name="industry" value={ deal.Industry } 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 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" value={ fmt.Sprintf("%.0f", deal.DealSize) } 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" selected?={ deal.Currency == "USD" }>USD</option>
<option value="EUR" selected?={ deal.Currency == "EUR" }>EUR</option>
<option value="GBP" selected?={ deal.Currency == "GBP" }>GBP</option>
</select>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">IOI Date</label>
<input type="date" name="ioi_date" value={ deal.IOIDate } 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" value={ deal.LOIDate } 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">Exclusivity End</label>
<input type="date" name="exclusivity_end" value={ deal.ExclusivityEnd } 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">{ deal.Description }</textarea>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="document.getElementById('editDealModal').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">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('uploadModal').classList.add('hidden')"></div>
<div class="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6">
<h2 class="text-lg font-bold mb-4">Upload Document</h2>
<form action="/deals/files/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
<form id="uploadForm" action="/deals/files/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
<input type="hidden" name="deal_id" value={ deal.ID }/>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">File</label>
<input type="file" name="file" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100"/>
<div id="uploadDropZone" class="border-2 border-dashed border-gray-700 rounded-lg p-4 text-center hover:border-teal-500/50 transition cursor-pointer" onclick="document.getElementById('fileInput').click()">
<svg class="w-6 h-6 text-gray-500 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p class="text-sm text-gray-400">Click or drag and drop files</p>
<p id="uploadFileName" class="text-xs text-teal-400 mt-1 hidden"></p>
</div>
<input type="file" id="fileInput" name="file" required class="hidden"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Folder</label>
<select name="folder_id" 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">
<select id="uploadFolderSelect" name="folder_id" 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="">Root (No folder)</option>
for _, f := range folders {
<option value={ f.ID }>{ f.Name }</option>
<option value={ f.ID } selected?={ f.ID == activeFolder }>{ f.Name }</option>
}
</select>
</div>
@ -292,6 +399,58 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
</div>
<!-- Upload Request List Modal (Section 3b) -->
<div id="uploadRequestListModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('uploadRequestListModal').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">Upload Request / Diligence List</h2>
<button onclick="document.getElementById('uploadRequestListModal').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/requests/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
<input type="hidden" name="deal_id" value={ deal.ID }/>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Request List File <span class="text-gray-600">(CSV: section, item_number, description, priority)</span></label>
<div class="border-2 border-dashed border-gray-700 rounded-lg p-4 text-center hover:border-teal-500/50 transition cursor-pointer" onclick="document.getElementById('reqListFile').click()">
<svg class="w-6 h-6 text-gray-500 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p class="text-sm text-gray-400">Click to upload CSV</p>
<p id="reqListFileName" class="text-xs text-teal-400 mt-1 hidden"></p>
</div>
<input type="file" id="reqListFile" name="request_list" accept=".csv,.xlsx,.xls" class="hidden" required onchange="if(this.files.length){var el=document.getElementById('reqListFileName');el.textContent=this.files[0].name;el.classList.remove('hidden')}"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Target Group</label>
<select name="target_group" 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="all">All Groups</option>
<option value="Meridian Capital">Meridian Capital</option>
<option value="Summit Health Equity">Summit Health Equity</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Upload Mode</label>
<select name="upload_mode" 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="add">Add to existing list</option>
<option value="replace">Replace existing list</option>
<option value="group_specific">Create group-specific list</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Folder Structure</label>
<select name="convert_folders" 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="no">Keep current folder structure</option>
<option value="yes">Create folders from request sections</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="document.getElementById('uploadRequestListModal').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">Upload & Process</button>
</div>
</form>
</div>
</div>
<!-- New Folder Modal -->
<div id="newFolderModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('newFolderModal').classList.add('hidden')"></div>
@ -320,7 +479,20 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
</div>
<!-- Deal ID for JS -->
<input type="hidden" id="currentDealID" value={ deal.ID }/>
<!-- Folder Context Menu -->
<div id="folderContextMenu" class="hidden fixed z-50 bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 min-w-[140px]">
<button onclick="reorderFolder('up')" class="block w-full text-left px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700 transition">Move Up</button>
<button onclick="reorderFolder('down')" class="block w-full text-left px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700 transition">Move Down</button>
<div class="border-t border-gray-700 my-1"></div>
<button onclick="renameFolder()" class="block w-full text-left px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700 transition">Rename</button>
<button onclick="deleteFolder()" class="block w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-gray-700 transition">Delete</button>
</div>
<script>
// Tab switching
function showTab(name) {
document.getElementById('panel-documents').style.display = name === 'documents' ? '' : 'none';
document.getElementById('panel-requests').style.display = name === 'requests' ? '' : 'none';
@ -340,12 +512,11 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
});
}
// Status dropdown
function toggleStatusDropdown(event, dealID, fileID) {
event.stopPropagation();
document.querySelectorAll('[data-dropdown]').forEach(function(el) {
if (el.getAttribute('data-dropdown') !== fileID) {
el.classList.add('hidden');
}
if (el.getAttribute('data-dropdown') !== fileID) el.classList.add('hidden');
});
var dd = document.querySelector('[data-dropdown="' + fileID + '"]');
dd.classList.toggle('hidden');
@ -356,16 +527,135 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function() {
window.location.reload();
});
}).then(function() { window.location.reload(); });
}
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown]').forEach(function(el) {
el.classList.add('hidden');
});
document.querySelectorAll('[data-dropdown]').forEach(function(el) { el.classList.add('hidden'); });
document.getElementById('folderContextMenu').classList.add('hidden');
});
// Upload modal: context-aware folder pre-selection
function openUploadModal() {
document.getElementById('uploadModal').classList.remove('hidden');
}
// Upload modal: drag-and-drop on the drop zone
var uploadDropZone = document.getElementById('uploadDropZone');
if (uploadDropZone) {
uploadDropZone.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('border-teal-500'); });
uploadDropZone.addEventListener('dragleave', function() { this.classList.remove('border-teal-500'); });
uploadDropZone.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border-teal-500');
if (e.dataTransfer.files.length > 0) {
document.getElementById('fileInput').files = e.dataTransfer.files;
var el = document.getElementById('uploadFileName');
el.textContent = e.dataTransfer.files[0].name;
el.classList.remove('hidden');
}
});
}
document.getElementById('fileInput').addEventListener('change', function() {
if (this.files.length > 0) {
var el = document.getElementById('uploadFileName');
el.textContent = this.files[0].name;
el.classList.remove('hidden');
}
});
// Drag-and-drop files directly onto the files panel
function handleFileDragOver(e) {
e.preventDefault();
document.getElementById('dropOverlay').classList.remove('hidden');
}
function handleFileDragLeave(e) {
if (!e.currentTarget.contains(e.relatedTarget)) {
document.getElementById('dropOverlay').classList.add('hidden');
}
}
function handleFileDrop(e) {
e.preventDefault();
document.getElementById('dropOverlay').classList.add('hidden');
if (e.dataTransfer.files.length > 0) {
var formData = new FormData();
formData.append('deal_id', document.getElementById('currentDealID').value);
formData.append('folder_id', new URLSearchParams(window.location.search).get('folder') || '');
for (var i = 0; i < e.dataTransfer.files.length; i++) {
formData.append('file', e.dataTransfer.files[i]);
}
fetch('/deals/files/upload', { method: 'POST', body: formData })
.then(function() { window.location.reload(); });
}
}
// Folder drag-to-reorder
var draggedFolderID = null;
function handleFolderDragStart(e) {
draggedFolderID = e.currentTarget.getAttribute('data-folder-id');
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.style.opacity = '0.5';
}
function handleFolderDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleFolderDrop(e) {
e.preventDefault();
var targetID = e.currentTarget.getAttribute('data-folder-id');
var dealID = e.currentTarget.getAttribute('data-deal-id');
if (draggedFolderID && targetID && draggedFolderID !== targetID) {
var form = new FormData();
form.append('folder_id', draggedFolderID);
form.append('deal_id', dealID);
form.append('direction', 'swap');
form.append('target_id', targetID);
fetch('/deals/folders/reorder', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
}
e.currentTarget.style.opacity = '1';
draggedFolderID = null;
}
// Folder context menu (right-click)
var ctxFolderID = null, ctxDealID = null;
function showFolderContextMenu(e, folderID, dealID) {
e.preventDefault();
ctxFolderID = folderID;
ctxDealID = dealID;
var menu = document.getElementById('folderContextMenu');
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
menu.classList.remove('hidden');
}
function reorderFolder(direction) {
var form = new FormData();
form.append('folder_id', ctxFolderID);
form.append('deal_id', ctxDealID);
form.append('direction', direction);
fetch('/deals/folders/reorder', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
}
function renameFolder() {
var name = prompt('New folder name:');
if (name) {
var form = new FormData();
form.append('folder_id', ctxFolderID);
form.append('deal_id', ctxDealID);
form.append('name', name);
fetch('/deals/folders/rename', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
}
}
function deleteFolder() {
if (confirm('Delete this folder?')) {
var form = new FormData();
form.append('folder_id', ctxFolderID);
form.append('deal_id', ctxDealID);
fetch('/deals/folders/delete', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
}
}
</script>
</div>
}

View File

@ -57,7 +57,7 @@ templ DealRooms(profile *model.Profile, deals []*model.Deal) {
<!-- 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="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-xl 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">