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
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
@ -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">⚙</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">⚙</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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue