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:
parent
0540d5abee
commit
24f4702f06
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) {
|
|||
currency = "USD"
|
||||
}
|
||||
if stage == "" {
|
||||
stage = "pipeline"
|
||||
stage = "prospect"
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 Legal Documents Technical DD/Architecture 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 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 Legal Documents Technical DD/Architecture 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 "' + this.value + '" 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 + '\')">×</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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue