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:
parent
ce2c68634d
commit
f06cce9089
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -24,7 +24,7 @@
|
|||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
</div>
|
||||
<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 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>
|
||||
|
|
@ -444,6 +444,8 @@
|
|||
${visBadge}
|
||||
<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">⚙</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>
|
||||
</tr>`;
|
||||
} else if (item.type === 'section') {
|
||||
|
|
@ -456,6 +458,7 @@
|
|||
${chevron}
|
||||
<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>
|
||||
<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>
|
||||
</tr>`;
|
||||
} 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 = `
|
||||
<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) {
|
||||
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
|
||||
renderTree();
|
||||
|
|
|
|||
Loading…
Reference in New Issue