feat: deal room folder management

Add folder create, rename, delete, and reorder handlers.
Add New Folder modal to deal room detail with parent folder selector.
Add sort_order field to folders for drag-to-reorder.
Order folders by sort_order in folder tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:51:04 -05:00
parent 233b20efa6
commit ae79ad12c3
5 changed files with 128 additions and 3 deletions

View File

@ -229,6 +229,8 @@ var additiveMigrationStmts = []string{
`ALTER TABLE deals ADD COLUMN buyer_can_comment INTEGER DEFAULT 1`,
`ALTER TABLE deals ADD COLUMN seller_can_comment INTEGER DEFAULT 1`,
`ALTER TABLE profiles ADD COLUMN buyer_group TEXT DEFAULT ''`,
// Section 6: folder sort order
`ALTER TABLE folders ADD COLUMN sort_order INTEGER DEFAULT 0`,
}
func seed(db *sql.DB) error {

View File

@ -212,6 +212,96 @@ func (h *Handler) handleFileStatusUpdate(w http.ResponseWriter, r *http.Request,
json.NewEncoder(w).Encode(map[string]string{"status": req.Status})
}
func (h *Handler) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
profile := getProfile(r.Context())
dealID := r.FormValue("deal_id")
name := strings.TrimSpace(r.FormValue("name"))
parentID := r.FormValue("parent_id")
if dealID == "" || name == "" {
http.Error(w, "Deal ID and name are required", 400)
return
}
folderID := generateID("folder")
h.db.Exec("INSERT INTO folders (id, deal_id, parent_id, name, created_by) VALUES (?, ?, ?, ?, ?)",
folderID, dealID, parentID, name, profile.ID)
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) handleFolderRename(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
folderID := r.FormValue("folder_id")
dealID := r.FormValue("deal_id")
name := strings.TrimSpace(r.FormValue("name"))
if folderID == "" || name == "" {
http.Error(w, "Missing fields", 400)
return
}
h.db.Exec("UPDATE folders SET name = ? WHERE id = ?", name, folderID)
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
folderID := r.FormValue("folder_id")
dealID := r.FormValue("deal_id")
if folderID == "" {
http.Error(w, "Missing folder ID", 400)
return
}
// Delete child folders
h.db.Exec("DELETE FROM folders WHERE parent_id = ?", folderID)
// Move files to root
h.db.Exec("UPDATE files SET folder_id = '' WHERE folder_id = ?", folderID)
h.db.Exec("DELETE FROM folders WHERE id = ?", folderID)
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) handleFolderReorder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
folderID := r.FormValue("folder_id")
dealID := r.FormValue("deal_id")
direction := r.FormValue("direction")
if folderID == "" {
http.Error(w, "Missing folder ID", 400)
return
}
var currentOrder int
h.db.QueryRow("SELECT COALESCE(sort_order, 0) FROM folders WHERE id = ?", folderID).Scan(&currentOrder)
if direction == "up" {
h.db.Exec("UPDATE folders SET sort_order = sort_order + 1 WHERE deal_id = ? AND COALESCE(sort_order, 0) = ?", dealID, currentOrder-1)
h.db.Exec("UPDATE folders SET sort_order = ? WHERE id = ?", currentOrder-1, folderID)
} else {
h.db.Exec("UPDATE folders SET sort_order = sort_order - 1 WHERE deal_id = ? AND COALESCE(sort_order, 0) = ?", dealID, currentOrder+1)
h.db.Exec("UPDATE folders SET sort_order = ? WHERE id = ?", currentOrder+1, folderID)
}
http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther)
}
func (h *Handler) getLastActivityByDeal(dealID string) *time.Time {
var t time.Time
err := h.db.QueryRow("SELECT MAX(created_at) FROM deal_activity WHERE deal_id = ?", dealID).Scan(&t)
@ -241,7 +331,7 @@ func (h *Handler) getDeals(profile *model.Profile) []*model.Deal {
}
func (h *Handler) getFolders(dealID string) []*model.Folder {
rows, err := h.db.Query("SELECT id, deal_id, parent_id, name, description FROM folders WHERE deal_id = ? ORDER BY name", dealID)
rows, err := h.db.Query("SELECT id, deal_id, parent_id, name, description FROM folders WHERE deal_id = ? ORDER BY COALESCE(sort_order, 0), name", dealID)
if err != nil {
return nil
}

View File

@ -75,8 +75,12 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/invites/accept", h.handleInviteAcceptPage)
mux.HandleFunc("/invites/accept-submit", h.handleInviteAccept)
// Deal creation
// Deal creation & folder management
mux.HandleFunc("/deals/create", h.requireAuth(h.handleCreateDeal))
mux.HandleFunc("/deals/folders/create", h.requireAuth(h.handleFolderCreate))
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))
// HTMX partials
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))

View File

@ -67,6 +67,7 @@ type Folder struct {
ParentID string
Name string
Description string
SortOrder int
CreatedBy string
CreatedAt time.Time
}

View File

@ -95,7 +95,7 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
}
if profile.Role != "buyer" {
<div class="mt-4 pt-3 border-t border-gray-800">
<button 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">
<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">
<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="M12 4v16m8-8H4"></path></svg>
New Folder
</button>
@ -207,6 +207,34 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</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>
<div class="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h2 class="text-lg font-bold mb-4">New Folder</h2>
<form action="/deals/folders/create" method="POST" 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">Folder Name</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">Parent Folder</label>
<select name="parent_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</option>
for _, f := range folders {
<option value={ f.ID }>{ f.Name }</option>
}
</select>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="document.getElementById('newFolderModal').classList.add('hidden')" class="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition">Cancel</button>
<button type="submit" class="px-4 py-2 bg-teal-500 text-white text-sm font-medium rounded-lg hover:bg-teal-600 transition">Create</button>
</div>
</form>
</div>
</div>
<script>
function showTab(name) {
document.getElementById('panel-documents').style.display = name === 'documents' ? '' : 'none';