diff --git a/api/handlers.go b/api/handlers.go index 79333a5..c89a79a 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1933,6 +1933,95 @@ func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) { // ImportRequests handles POST /api/projects/{projectID}/requests/import // 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. func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) @@ -1961,6 +2050,13 @@ func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) { listName := strings.TrimSpace(body.Name) 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} rlDataJSON, _ := json.Marshal(rlData) 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, created_at, updated_at, created_by) VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, - requestListID, projectID, projectID, lib.TypeRequestList, 1, 0, + requestListID, projectID, projectID, lib.TypeRequestList, 1, sortOrder, nil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom, "", "", "", 1, nil, nil, 1, diff --git a/api/routes.go b/api/routes.go index 2a63df8..dd6079c 100644 --- a/api/routes.go +++ b/api/routes.go @@ -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/tree", h.ListRequestTree) 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) // Request detail diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 80ebd3f..4394049 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/dealspace b/dealspace index 8bb2e9e..2fc8fd5 100755 Binary files a/dealspace and b/dealspace differ diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index b3db51b..56581fe 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -24,7 +24,7 @@
- +
@@ -122,7 +122,7 @@
- +
@@ -444,6 +444,8 @@ ${visBadge} ${childCount} items + + `; } else if (item.type === 'section') { @@ -456,6 +458,7 @@ ${chevron} ${escHtml(name)} ${item.children_count||0} requests + `; } else { @@ -540,6 +543,75 @@ 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 = ` + + +
+ + + +
+ `; + + // 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) { if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id); renderTree();