fix: stage constraint migration + folder drag reparenting
- Auto-migrate deals table: old CHECK constraint ('pipeline','loi',...) → new
stages ('prospect','internal','initial_marketing','ioi','loi','closed')
Maps legacy values: pipeline→prospect, initial_review→initial_marketing,
due_diligence→ioi, final_negotiation→loi, dead→closed
- Add POST /deals/folders/reparent endpoint with circular-ref guard
- Child folders are now draggable with full drag support
- Drop onto top 40% of a folder → reorder at same level (existing behaviour)
- Drop onto bottom 60% of a folder → make dragged folder a child of target
- Root drop zone appears when dragging a subfolder: drop here to promote to root
- Visual teal outline on hover shows where folder will land
This commit is contained in:
parent
24f4702f06
commit
13effdce40
|
|
@ -4,6 +4,7 @@ import (
|
|||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Migrate(db *sql.DB) error {
|
||||
|
|
@ -36,6 +37,9 @@ func Migrate(db *sql.DB) error {
|
|||
db.Exec(stmt)
|
||||
}
|
||||
|
||||
// Fix stage CHECK constraint if DB was created with old schema
|
||||
fixDealStageConstraint(db)
|
||||
|
||||
// Seed demo data if empty
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count)
|
||||
|
|
@ -260,6 +264,84 @@ var additiveMigrationStmts = []string{
|
|||
`ALTER TABLE deal_activity ADD COLUMN time_spent_seconds INTEGER DEFAULT 0`,
|
||||
}
|
||||
|
||||
// fixDealStageConstraint recreates the deals table if it was created with the
|
||||
// old stage CHECK constraint (pipeline/initial_review/…) before the new stages
|
||||
// (prospect/internal/initial_marketing/ioi/loi/closed) were introduced.
|
||||
// SQLite doesn't support ALTER TABLE … MODIFY COLUMN, so we do the rename dance.
|
||||
func fixDealStageConstraint(db *sql.DB) {
|
||||
var tableSql string
|
||||
if err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='deals'").Scan(&tableSql); err != nil {
|
||||
return
|
||||
}
|
||||
// Already correct — new stages present in the constraint
|
||||
if strings.Contains(tableSql, "'prospect'") {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Migrating: updating deals stage CHECK constraint to new values…")
|
||||
db.Exec("PRAGMA foreign_keys = OFF")
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE deals_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
target_company TEXT DEFAULT '',
|
||||
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 '',
|
||||
loi_date TEXT DEFAULT '',
|
||||
exclusivity_end_date TEXT DEFAULT '',
|
||||
expected_close_date TEXT DEFAULT '',
|
||||
close_probability INTEGER DEFAULT 0,
|
||||
is_archived BOOLEAN DEFAULT 0,
|
||||
created_by TEXT DEFAULT '',
|
||||
industry TEXT DEFAULT '',
|
||||
buyer_can_comment INTEGER DEFAULT 1,
|
||||
seller_can_comment INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id)
|
||||
)`,
|
||||
// Map old stage values → new equivalents; anything unknown → 'prospect'
|
||||
`INSERT INTO deals_new (id, organization_id, name, description, target_company, stage,
|
||||
deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date,
|
||||
close_probability, is_archived, created_by, industry, buyer_can_comment, seller_can_comment,
|
||||
created_at, updated_at)
|
||||
SELECT id, organization_id, name, description, target_company,
|
||||
CASE stage
|
||||
WHEN 'pipeline' THEN 'prospect'
|
||||
WHEN 'initial_review' THEN 'initial_marketing'
|
||||
WHEN 'due_diligence' THEN 'ioi'
|
||||
WHEN 'final_negotiation' THEN 'loi'
|
||||
WHEN 'dead' THEN 'closed'
|
||||
ELSE CASE WHEN stage IN ('prospect','internal','initial_marketing','ioi','loi','closed')
|
||||
THEN stage ELSE 'prospect' END
|
||||
END,
|
||||
deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date,
|
||||
close_probability, is_archived, created_by,
|
||||
COALESCE(industry,''), COALESCE(buyer_can_comment,1), COALESCE(seller_can_comment,1),
|
||||
created_at, updated_at
|
||||
FROM deals`,
|
||||
`DROP TABLE deals`,
|
||||
`ALTER TABLE deals_new RENAME TO deals`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_deals_org ON deals(organization_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_deals_stage ON deals(stage)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
log.Printf("fixDealStageConstraint error: %v", err)
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
return
|
||||
}
|
||||
}
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
log.Println("deals stage constraint migration complete")
|
||||
}
|
||||
|
||||
func seed(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
// Organization
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
|
@ -302,6 +303,63 @@ func (h *Handler) handleFolderReorder(w http.ResponseWriter, r *http.Request) {
|
|||
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) handleFolderReparent(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
profile := getProfile(r.Context())
|
||||
folderID := r.FormValue("folder_id")
|
||||
newParentID := r.FormValue("new_parent_id") // empty string = root
|
||||
dealID := r.FormValue("deal_id")
|
||||
|
||||
if folderID == "" || dealID == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Can't parent a folder to itself
|
||||
if folderID == newParentID {
|
||||
http.Error(w, "Cannot parent a folder to itself", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify deal belongs to this org
|
||||
var orgID string
|
||||
if err := h.db.QueryRow("SELECT organization_id FROM deals WHERE id = ?", dealID).Scan(&orgID); err != nil || orgID != profile.OrganizationID {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against circular references (new parent can't be a descendant of folder)
|
||||
if newParentID != "" && folderIsDescendant(h.db, dealID, folderID, newParentID) {
|
||||
http.Error(w, "Cannot make a folder a child of its own descendant", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.db.Exec("UPDATE folders SET parent_id = ? WHERE id = ? AND deal_id = ?", newParentID, folderID, dealID)
|
||||
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// folderIsDescendant returns true if targetID is a descendant of ancestorID within a deal.
|
||||
func folderIsDescendant(db *sql.DB, dealID, ancestorID, targetID string) bool {
|
||||
current := targetID
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
if current == "" || seen[current] {
|
||||
return false
|
||||
}
|
||||
seen[current] = true
|
||||
var parentID string
|
||||
if err := db.QueryRow("SELECT parent_id FROM folders WHERE id = ? AND deal_id = ?", current, dealID).Scan(&parentID); err != nil {
|
||||
return false
|
||||
}
|
||||
if parentID == ancestorID {
|
||||
return true
|
||||
}
|
||||
current = parentID
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleDealSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// /deals/search/{dealID}?q=...
|
||||
dealID := strings.TrimPrefix(r.URL.Path, "/deals/search/")
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
|
|||
mux.HandleFunc("/deals/folders/rename", h.requireAuth(h.handleFolderRename))
|
||||
mux.HandleFunc("/deals/folders/delete", h.requireAuth(h.handleFolderDelete))
|
||||
mux.HandleFunc("/deals/folders/reorder", h.requireAuth(h.handleFolderReorder))
|
||||
mux.HandleFunc("/deals/folders/reparent", h.requireAuth(h.handleFolderReparent))
|
||||
|
||||
// File management
|
||||
mux.HandleFunc("/deals/files/upload", h.requireAuth(h.handleFileUpload))
|
||||
|
|
|
|||
|
|
@ -92,19 +92,26 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
<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)"
|
||||
<div class="mb-1 folder-item" draggable="true"
|
||||
data-folder-id={ folder.ID } data-folder-parent="" data-deal-id={ deal.ID }
|
||||
ondragstart="handleFolderDragStart(event)" ondragend="handleFolderDragEnd(event)"
|
||||
ondragover="handleFolderDragOver(event)" ondragleave="handleFolderDragLeave(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) }>
|
||||
<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 transition-colors", 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 -->
|
||||
<!-- Child folders (now draggable + reparentable) -->
|
||||
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) }>
|
||||
<div class="ml-4 folder-item" draggable="true"
|
||||
data-folder-id={ child.ID } data-folder-parent={ folder.ID } data-deal-id={ deal.ID }
|
||||
ondragstart="handleFolderDragStart(event)" ondragend="handleFolderDragEnd(event)"
|
||||
ondragover="handleFolderDragOver(event)" ondragleave="handleFolderDragLeave(event)" ondrop="handleFolderDrop(event)"
|
||||
oncontextmenu={ templ.ComponentScript{Call: fmt.Sprintf("showFolderContextMenu(event, '%s', '%s')", child.ID, deal.ID)} }>
|
||||
<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 transition-colors", 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-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">{ child.Name }</span>
|
||||
</a>
|
||||
|
|
@ -115,6 +122,11 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
}
|
||||
}
|
||||
</div>
|
||||
<!-- Root drop zone: drag a subfolder here to promote it to root level -->
|
||||
<div id="rootDropZone" class="hidden mt-2 py-2 px-2 border-2 border-dashed border-teal-500/40 rounded-lg text-center text-xs text-teal-400/60 transition-colors"
|
||||
ondragover="handleRootZoneDragOver(event)" ondragleave="handleRootZoneDragLeave(event)" ondrop="handleRootZoneDrop(event)">
|
||||
↑ Drop here to make root-level folder
|
||||
</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">
|
||||
|
|
@ -589,22 +601,75 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
}
|
||||
}
|
||||
|
||||
// Folder drag-to-reorder
|
||||
// Folder drag — supports reorder AND reparenting (make child / make root)
|
||||
var draggedFolderID = null;
|
||||
var draggedFolderParent = null;
|
||||
var draggedDealID = null;
|
||||
|
||||
function handleFolderDragStart(e) {
|
||||
draggedFolderID = e.currentTarget.getAttribute('data-folder-id');
|
||||
// Stop bubbling so inner child drags don't fire parent's dragstart too
|
||||
e.stopPropagation();
|
||||
draggedFolderID = e.currentTarget.getAttribute('data-folder-id');
|
||||
draggedFolderParent = e.currentTarget.getAttribute('data-folder-parent') || '';
|
||||
draggedDealID = e.currentTarget.getAttribute('data-deal-id');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
e.currentTarget.style.opacity = '0.4';
|
||||
// Show root zone only when a subfolder is being dragged
|
||||
if (draggedFolderParent !== '') {
|
||||
document.getElementById('rootDropZone').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderDragEnd(e) {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
document.getElementById('rootDropZone').classList.add('hidden');
|
||||
// Clear all hover highlights
|
||||
document.querySelectorAll('.folder-item').forEach(function(el) {
|
||||
el.querySelector('a').style.outline = '';
|
||||
});
|
||||
}
|
||||
|
||||
function handleFolderDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.stopPropagation();
|
||||
var targetID = e.currentTarget.getAttribute('data-folder-id');
|
||||
if (draggedFolderID && targetID !== draggedFolderID) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// Visual: highlight the folder as a potential parent
|
||||
e.currentTarget.querySelector('a').style.outline = '2px solid rgba(20,184,166,0.5)';
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderDragLeave(e) {
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget.querySelector('a')) {
|
||||
e.currentTarget.querySelector('a').style.outline = '';
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
e.stopPropagation();
|
||||
var targetID = e.currentTarget.getAttribute('data-folder-id');
|
||||
var dealID = e.currentTarget.getAttribute('data-deal-id');
|
||||
if (e.currentTarget.querySelector('a')) {
|
||||
e.currentTarget.querySelector('a').style.outline = '';
|
||||
}
|
||||
document.getElementById('rootDropZone').classList.add('hidden');
|
||||
|
||||
if (!draggedFolderID || !targetID || draggedFolderID === targetID) {
|
||||
draggedFolderID = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine drop intent: top 40% of element → reorder (same level),
|
||||
// bottom 60% → make the dragged folder a child of the target
|
||||
var rect = e.currentTarget.getBoundingClientRect();
|
||||
var relY = e.clientY - rect.top;
|
||||
var ratio = relY / rect.height;
|
||||
|
||||
if (ratio < 0.4) {
|
||||
// Reorder at same level (existing behaviour)
|
||||
var form = new FormData();
|
||||
form.append('folder_id', draggedFolderID);
|
||||
form.append('deal_id', dealID);
|
||||
|
|
@ -612,8 +677,38 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
form.append('target_id', targetID);
|
||||
fetch('/deals/folders/reorder', { method: 'POST', body: form })
|
||||
.then(function() { window.location.reload(); });
|
||||
} else {
|
||||
// Reparent: make dragged folder a child of target
|
||||
var form = new FormData();
|
||||
form.append('folder_id', draggedFolderID);
|
||||
form.append('new_parent_id', targetID);
|
||||
form.append('deal_id', dealID);
|
||||
fetch('/deals/folders/reparent', { method: 'POST', body: form })
|
||||
.then(function() { window.location.reload(); });
|
||||
}
|
||||
e.currentTarget.style.opacity = '1';
|
||||
draggedFolderID = null;
|
||||
}
|
||||
|
||||
// Root drop zone — promotes a subfolder to root level
|
||||
function handleRootZoneDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
document.getElementById('rootDropZone').classList.add('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
|
||||
}
|
||||
function handleRootZoneDragLeave(e) {
|
||||
document.getElementById('rootDropZone').classList.remove('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
|
||||
}
|
||||
function handleRootZoneDrop(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('rootDropZone').classList.add('hidden');
|
||||
document.getElementById('rootDropZone').classList.remove('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
|
||||
if (!draggedFolderID || !draggedDealID) return;
|
||||
var form = new FormData();
|
||||
form.append('folder_id', draggedFolderID);
|
||||
form.append('new_parent_id', ''); // root
|
||||
form.append('deal_id', draggedDealID);
|
||||
fetch('/deals/folders/reparent', { method: 'POST', body: form })
|
||||
.then(function() { window.location.reload(); });
|
||||
draggedFolderID = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue