feat: inline add section/request in tree; fix New List sort_order; Import List rename; + Section/+ Request buttons on list rows

This commit is contained in:
James 2026-03-14 19:16:22 -04:00
parent ce2c68634d
commit f06cce9089
5 changed files with 173 additions and 3 deletions

View File

@ -1933,6 +1933,95 @@ func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) {
// ImportRequests handles POST /api/projects/{projectID}/requests/import // ImportRequests handles POST /api/projects/{projectID}/requests/import
// Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams // Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams
// CreateSection creates a new section under a request_list.
func (h *Handlers) CreateSection(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied"); return
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" || body.ParentID == "" {
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "name and parent_id required"); return
}
projectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "key error"); return }
var maxSort int
h.DB.Conn.QueryRow(
`SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE parent_id=? AND deleted_at IS NULL`, body.ParentID,
).Scan(&maxSort)
now := time.Now().UTC().Format(time.RFC3339)
secID := uuid.New().String()
name := strings.TrimSpace(body.Name)
secData := lib.SectionData{Name: name}
secDataJSON, _ := json.Marshal(secData)
sumPacked, _ := lib.Pack(projectKey, name)
dataPacked, _ := lib.Pack(projectKey, string(secDataJSON))
_, err = h.DB.Conn.Exec(
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
secID, projectID, body.ParentID, lib.TypeSection, 2, maxSort+1000,
nil, nil, sumPacked, dataPacked, lib.StagePreDataroom,
"", "", "", 1, nil, nil, 1, now, now, actorID,
)
if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create section"); return }
JSONResponse(w, http.StatusOK, map[string]any{"entry_id": secID, "name": name})
}
// CreateRequest creates a new blank request under a section or request_list.
func (h *Handlers) CreateRequest(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied"); return
}
var body struct {
Title string `json:"title"`
ParentID string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" || body.ParentID == "" {
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "title and parent_id required"); return
}
projectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "key error"); return }
// Determine depth from parent
var parentDepth int
h.DB.Conn.QueryRow(`SELECT depth FROM entries WHERE entry_id=?`, body.ParentID).Scan(&parentDepth)
var maxSort int
h.DB.Conn.QueryRow(
`SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE parent_id=? AND deleted_at IS NULL`, body.ParentID,
).Scan(&maxSort)
now := time.Now().UTC().Format(time.RFC3339)
reqID := uuid.New().String()
title := strings.TrimSpace(body.Title)
reqData := lib.RequestData{Title: title, Status: "open", Priority: "medium"}
reqDataJSON, _ := json.Marshal(reqData)
sumPacked, _ := lib.Pack(projectKey, title)
dataPacked, _ := lib.Pack(projectKey, string(reqDataJSON))
_, err = h.DB.Conn.Exec(
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
reqID, projectID, body.ParentID, lib.TypeRequest, parentDepth+1, maxSort+100,
nil, nil, sumPacked, dataPacked, lib.StagePreDataroom,
"", "", "", 1, nil, nil, 1, now, now, actorID,
)
if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create request"); return }
JSONResponse(w, http.StatusOK, map[string]any{"entry_id": reqID, "title": title})
}
// CreateRequestList creates a new empty request list for a project. // CreateRequestList creates a new empty request list for a project.
func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context()) actorID := UserIDFromContext(r.Context())
@ -1961,6 +2050,13 @@ func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) {
listName := strings.TrimSpace(body.Name) listName := strings.TrimSpace(body.Name)
requestListID := uuid.New().String() requestListID := uuid.New().String()
// Compute sort_order: place after last existing list
var maxSort int
h.DB.Conn.QueryRow(
`SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE project_id=? AND type='request_list' AND deleted_at IS NULL`, projectID,
).Scan(&maxSort)
sortOrder := maxSort + 1000
rlData := lib.RequestListData{Name: listName} rlData := lib.RequestListData{Name: listName}
rlDataJSON, _ := json.Marshal(rlData) rlDataJSON, _ := json.Marshal(rlData)
rlSummaryPacked, _ := lib.Pack(projectKey, listName) rlSummaryPacked, _ := lib.Pack(projectKey, listName)
@ -1973,7 +2069,7 @@ func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) {
version, deleted_at, deleted_by, key_version, version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by) created_at, updated_at, created_by)
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
requestListID, projectID, projectID, lib.TypeRequestList, 1, 0, requestListID, projectID, projectID, lib.TypeRequestList, 1, sortOrder,
nil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom, nil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom,
"", "", "", "", "", "",
1, nil, nil, 1, 1, nil, nil, 1,

View File

@ -71,6 +71,8 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/projects/{projectID}/requests", h.ListRequests) r.Get("/projects/{projectID}/requests", h.ListRequests)
r.Get("/projects/{projectID}/requests/tree", h.ListRequestTree) r.Get("/projects/{projectID}/requests/tree", h.ListRequestTree)
r.Post("/projects/{projectID}/requests", h.CreateRequestList) r.Post("/projects/{projectID}/requests", h.CreateRequestList)
r.Post("/projects/{projectID}/requests/new", h.CreateRequest)
r.Post("/projects/{projectID}/sections", h.CreateSection)
r.Post("/projects/{projectID}/requests/import", h.ImportRequests) r.Post("/projects/{projectID}/requests/import", h.ImportRequests)
// Request detail // Request detail

Binary file not shown.

BIN
dealspace

Binary file not shown.

View File

@ -24,7 +24,7 @@
<div class="ml-auto flex items-center gap-2 pb-2"> <div class="ml-auto flex items-center gap-2 pb-2">
<span id="projectStatus" class="px-2 py-0.5 rounded text-xs font-medium"></span> <span id="projectStatus" class="px-2 py-0.5 rounded text-xs font-medium"></span>
<button onclick="openCreateListModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">+ New List</button> <button onclick="openCreateListModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">+ New List</button>
<button onclick="openImportModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">Import</button> <button onclick="openImportModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">Import List</button>
<button id="detailToggle" onclick="toggleDetailMode()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-[#cbd5e1] hover:text-white rounded text-sm font-medium transition">Summary View</button> <button id="detailToggle" onclick="toggleDetailMode()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-[#cbd5e1] hover:text-white rounded text-sm font-medium transition">Summary View</button>
</div> </div>
</div> </div>
@ -122,7 +122,7 @@
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button onclick="closeImportModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button> <button onclick="closeImportModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="importBtn" onclick="submitImport()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import</button> <button id="importBtn" onclick="submitImport()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import List</button>
</div> </div>
</div> </div>
</div> </div>
@ -444,6 +444,8 @@
${visBadge} ${visBadge}
<span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${childCount} items</span> <span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${childCount} items</span>
<button onclick="editVisibility('${item.entry_id}')" style="margin-left:6px;background:none;border:none;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 4px" title="Change visibility">&#9881;</button> <button onclick="editVisibility('${item.entry_id}')" style="margin-left:6px;background:none;border:none;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 4px" title="Change visibility">&#9881;</button>
<button onclick="startInlineAdd('section','${item.entry_id}')" style="margin-left:10px;background:none;border:1px solid var(--ds-bd);border-radius:4px;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 8px" title="Add section">+ Section</button>
<button onclick="startInlineAdd('request','${item.entry_id}')" style="margin-left:4px;background:none;border:1px solid var(--ds-bd);border-radius:4px;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 8px" title="Add request">+ Request</button>
</td> </td>
</tr>`; </tr>`;
} else if (item.type === 'section') { } else if (item.type === 'section') {
@ -456,6 +458,7 @@
${chevron} ${chevron}
<span style="color:var(--ds-tx);font-weight:600;font-size:13px;margin-left:4px">${escHtml(name)}</span> <span style="color:var(--ds-tx);font-weight:600;font-size:13px;margin-left:4px">${escHtml(name)}</span>
<span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${item.children_count||0} requests</span> <span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${item.children_count||0} requests</span>
<button onclick="startInlineAdd('request','${item.entry_id}')" style="margin-left:10px;background:none;border:1px solid var(--ds-bd);border-radius:4px;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 8px" title="Add request">+ Request</button>
</td> </td>
</tr>`; </tr>`;
} else { } else {
@ -540,6 +543,75 @@
tbody.innerHTML = html; tbody.innerHTML = html;
} }
// ---- Inline add section / request ----
let inlineAddRow = null;
function startInlineAdd(type, parentId) {
// Remove any existing inline row
const existing = document.getElementById('inlineAddRow');
if (existing) existing.remove();
const label = type === 'section' ? 'Section name' : 'Request text';
const placeholder = type === 'section' ? 'e.g. Legal, Financial, Operations' : 'e.g. Provide last 3 years audited financials';
const tr = document.createElement('tr');
tr.id = 'inlineAddRow';
tr.setAttribute('data-parent', parentId);
tr.setAttribute('data-addtype', type);
tr.innerHTML = `
<td></td>
<td colspan="12" style="padding:8px 10px 8px 40px">
<div style="display:flex;align-items:center;gap:8px">
<input id="inlineAddInput" type="text" placeholder="${placeholder}"
style="flex:1;padding:6px 10px;border-radius:6px;border:1px solid var(--ds-ac);background:var(--ds-bg);color:var(--ds-tx);font-size:13px;outline:none"
onkeydown="if(event.key==='Enter')submitInlineAdd();if(event.key==='Escape')cancelInlineAdd()">
<button onclick="submitInlineAdd()" style="padding:5px 14px;border-radius:6px;border:none;background:var(--ds-ac);color:var(--ds-act);font-size:12px;font-weight:600;cursor:pointer">Add</button>
<button onclick="cancelInlineAdd()" style="padding:5px 10px;border-radius:6px;border:1px solid var(--ds-bd);background:none;color:var(--ds-tx3);font-size:12px;cursor:pointer">Cancel</button>
</div>
</td>`;
// Insert after the parent row
const parentRow = document.querySelector(`tr[data-id="${parentId}"]`);
if (parentRow && parentRow.nextSibling) {
parentRow.parentNode.insertBefore(tr, parentRow.nextSibling);
} else if (parentRow) {
parentRow.parentNode.appendChild(tr);
}
document.getElementById('inlineAddInput').focus();
}
function cancelInlineAdd() {
const el = document.getElementById('inlineAddRow');
if (el) el.remove();
}
async function submitInlineAdd() {
const tr = document.getElementById('inlineAddRow');
if (!tr) return;
const type = tr.getAttribute('data-addtype');
const parentId = tr.getAttribute('data-parent');
const text = document.getElementById('inlineAddInput').value.trim();
if (!text) return;
try {
const endpoint = type === 'section'
? `/api/projects/${projectID}/sections`
: `/api/projects/${projectID}/requests/new`;
const res = await fetchAPI(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: text, title: text, parent_id: parentId })
});
if (!res.ok) throw new Error('Failed');
cancelInlineAdd();
await loadRequestTree();
} catch(e) {
document.getElementById('inlineAddInput').style.borderColor = 'red';
}
}
function toggleCollapse(id) { function toggleCollapse(id) {
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id); if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
renderTree(); renderTree();