feat: template system — save/load request templates in DB; Use Template button; wizard uses ImportTemplate; save-as-template after import
This commit is contained in:
parent
4a7698ed20
commit
cd2b67edd2
200
api/handlers.go
200
api/handlers.go
|
|
@ -1966,6 +1966,206 @@ func (h *Handlers) ServeWebsiteSOC2(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// ListRequests handles GET /api/projects/{projectID}/requests
|
||||
// Returns all request entries for a project, sorted by section + item_number
|
||||
// ListTemplates returns all saved request templates.
|
||||
func (h *Handlers) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := lib.ListTemplates(h.DB, h.Cfg)
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list templates")
|
||||
return
|
||||
}
|
||||
result := []map[string]any{}
|
||||
for _, t := range templates {
|
||||
var data lib.RequestTemplateData
|
||||
json.Unmarshal([]byte(t.DataText), &data)
|
||||
result = append(result, map[string]any{
|
||||
"entry_id": t.EntryID,
|
||||
"name": data.Name,
|
||||
"description": data.Description,
|
||||
"item_count": len(data.Items),
|
||||
"created_at": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
// Also include built-in disk templates as a fallback
|
||||
JSONResponse(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SaveTemplate saves a request list as a reusable template.
|
||||
func (h *Handlers) SaveTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
actorID := UserIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Items []lib.RequestTemplateItem `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "name and items required")
|
||||
return
|
||||
}
|
||||
data := lib.RequestTemplateData{
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Description: body.Description,
|
||||
Items: body.Items,
|
||||
}
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
entry := &lib.Entry{
|
||||
ProjectID: lib.TemplateProjectID,
|
||||
ParentID: lib.TemplateProjectID,
|
||||
Type: lib.TypeRequestTemplate,
|
||||
Depth: 1,
|
||||
SortOrder: 0,
|
||||
SummaryText: data.Name,
|
||||
DataText: string(dataJSON),
|
||||
Stage: lib.StageDataroom,
|
||||
}
|
||||
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to save template")
|
||||
return
|
||||
}
|
||||
JSONResponse(w, http.StatusOK, map[string]any{"entry_id": entry.EntryID, "name": data.Name})
|
||||
}
|
||||
|
||||
// ImportTemplate imports a template (built-in or saved) into a project as a new request list.
|
||||
func (h *Handlers) ImportTemplate(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 {
|
||||
TemplateID string `json:"template_id"` // entry_id of saved template, or built-in name
|
||||
Name string `json:"name"` // override list name (optional)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TemplateID == "" {
|
||||
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "template_id required")
|
||||
return
|
||||
}
|
||||
|
||||
var tmplData lib.RequestTemplateData
|
||||
|
||||
// Try loading from DB first
|
||||
tmplEntry, err := lib.TemplateByID(h.DB, h.Cfg, body.TemplateID)
|
||||
if err == nil && tmplEntry != nil {
|
||||
json.Unmarshal([]byte(tmplEntry.DataText), &tmplData)
|
||||
} else {
|
||||
// Fall back to disk templates
|
||||
dirs := []string{"templates", "/opt/dealspace/templates"}
|
||||
loaded := false
|
||||
for _, dir := range dirs {
|
||||
fp := dir + "/" + body.TemplateID + ".json"
|
||||
if data, err := os.ReadFile(fp); err == nil {
|
||||
json.Unmarshal(data, &tmplData)
|
||||
loaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !loaded {
|
||||
ErrorResponse(w, http.StatusNotFound, "not_found", "Template not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
listName := strings.TrimSpace(body.Name)
|
||||
if listName == "" {
|
||||
listName = tmplData.Name
|
||||
}
|
||||
|
||||
// Create the request list
|
||||
siblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID)
|
||||
listCount := 0
|
||||
for _, s := range siblings {
|
||||
if s.Type == lib.TypeRequestList {
|
||||
listCount++
|
||||
}
|
||||
}
|
||||
rlData := lib.RequestListData{Name: listName}
|
||||
rlDataJSON, _ := json.Marshal(rlData)
|
||||
listEntry := &lib.Entry{
|
||||
ProjectID: projectID,
|
||||
ParentID: projectID,
|
||||
Type: lib.TypeRequestList,
|
||||
Depth: 1,
|
||||
SortOrder: (listCount + 1) * 1000,
|
||||
SummaryText: listName,
|
||||
DataText: string(rlDataJSON),
|
||||
Stage: lib.StagePreDataroom,
|
||||
}
|
||||
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, listEntry); err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create request list")
|
||||
return
|
||||
}
|
||||
|
||||
// Group items by section, create section entries then requests
|
||||
type sectionGroup struct {
|
||||
name string
|
||||
items []lib.RequestTemplateItem
|
||||
}
|
||||
var sections []sectionGroup
|
||||
sectionIndex := map[string]int{}
|
||||
for _, item := range tmplData.Items {
|
||||
sec := item.Section
|
||||
if sec == "" {
|
||||
sec = "General"
|
||||
}
|
||||
if idx, ok := sectionIndex[sec]; ok {
|
||||
sections[idx].items = append(sections[idx].items, item)
|
||||
} else {
|
||||
sectionIndex[sec] = len(sections)
|
||||
sections = append(sections, sectionGroup{name: sec, items: []lib.RequestTemplateItem{item}})
|
||||
}
|
||||
}
|
||||
|
||||
for si, sg := range sections {
|
||||
secData := lib.SectionData{Name: sg.name}
|
||||
secDataJSON, _ := json.Marshal(secData)
|
||||
secEntry := &lib.Entry{
|
||||
ProjectID: projectID,
|
||||
ParentID: listEntry.EntryID,
|
||||
Type: lib.TypeSection,
|
||||
Depth: 2,
|
||||
SortOrder: (si + 1) * 1000,
|
||||
SummaryText: sg.name,
|
||||
DataText: string(secDataJSON),
|
||||
Stage: lib.StagePreDataroom,
|
||||
}
|
||||
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, secEntry); err != nil {
|
||||
continue
|
||||
}
|
||||
for ri, item := range sg.items {
|
||||
reqData := lib.RequestData{
|
||||
Title: item.Title,
|
||||
ItemNumber: item.ItemNumber,
|
||||
Section: sg.name,
|
||||
Priority: item.Priority,
|
||||
Status: "open",
|
||||
}
|
||||
if reqData.Priority == "" {
|
||||
reqData.Priority = "medium"
|
||||
}
|
||||
reqDataJSON, _ := json.Marshal(reqData)
|
||||
reqEntry := &lib.Entry{
|
||||
ProjectID: projectID,
|
||||
ParentID: secEntry.EntryID,
|
||||
Type: lib.TypeRequest,
|
||||
Depth: 3,
|
||||
SortOrder: (ri + 1) * 100,
|
||||
SummaryText: item.Title,
|
||||
DataText: string(reqDataJSON),
|
||||
Stage: lib.StagePreDataroom,
|
||||
}
|
||||
lib.EntryWrite(h.DB, h.Cfg, actorID, reqEntry)
|
||||
}
|
||||
}
|
||||
|
||||
JSONResponse(w, http.StatusOK, map[string]any{
|
||||
"entry_id": listEntry.EntryID,
|
||||
"name": listName,
|
||||
"sections": len(sections),
|
||||
"items": len(tmplData.Items),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) {
|
||||
actorID := UserIDFromContext(r.Context())
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
|||
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-template", h.ImportTemplate)
|
||||
r.Get("/templates", h.ListTemplates)
|
||||
r.Post("/templates", h.SaveTemplate)
|
||||
|
||||
// Request detail
|
||||
r.Get("/requests/{requestID}", h.GetRequestDetail)
|
||||
|
|
|
|||
|
|
@ -414,6 +414,25 @@ func unpackEntry(cfg *Config, e *Entry) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// unpackEntryWithKey decrypts an entry using a pre-derived key (used for templates).
|
||||
func unpackEntryWithKey(key []byte, e *Entry) error {
|
||||
if len(e.Summary) > 0 {
|
||||
text, err := Unpack(key, e.Summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.SummaryText = text
|
||||
}
|
||||
if len(e.Data) > 0 {
|
||||
text, err := Unpack(key, e.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.DataText = text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1102,6 +1121,45 @@ func OAuthTokenRevoke(db *DB, tokenStr string) error {
|
|||
}
|
||||
|
||||
// SetSessionTestRole updates the test_role on a session (super-admin only).
|
||||
// ListTemplates returns all global request templates.
|
||||
func ListTemplates(db *DB, cfg *Config) ([]Entry, error) {
|
||||
filter := EntryFilter{
|
||||
ProjectID: TemplateProjectID,
|
||||
Type: TypeRequestTemplate,
|
||||
}
|
||||
entries, err := entryQuery(db, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Use system key for template project
|
||||
templateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range entries {
|
||||
if err := unpackEntryWithKey(templateKey, &entries[i]); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// TemplateByID returns a single request template by entry_id.
|
||||
func TemplateByID(db *DB, cfg *Config, entryID string) (*Entry, error) {
|
||||
e, err := entryReadSystem(db, entryID)
|
||||
if err != nil || e == nil {
|
||||
return e, err
|
||||
}
|
||||
templateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := unpackEntryWithKey(templateKey, e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func SetSessionTestRole(db *DB, sessionID, role string) error {
|
||||
_, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID)
|
||||
return err
|
||||
|
|
|
|||
14
lib/rbac.go
14
lib/rbac.go
|
|
@ -11,6 +11,10 @@ var (
|
|||
ErrInsufficientOps = errors.New("insufficient permissions")
|
||||
)
|
||||
|
||||
// TemplateProjectID is the well-known project_id for global request templates.
|
||||
// All authenticated users have full rwdm access to this "project".
|
||||
const TemplateProjectID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
// CheckAccess verifies that actorID has the required operation on the given project/entry.
|
||||
// op is one of "r", "w", "d", "m" (read, write, delete, manage).
|
||||
// Returns the matching Access grant or an error.
|
||||
|
|
@ -29,6 +33,16 @@ func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op stri
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Template project — all authenticated users have full access
|
||||
if projectID == TemplateProjectID && actorID != "" {
|
||||
return &Access{
|
||||
UserID: actorID,
|
||||
ProjectID: TemplateProjectID,
|
||||
Role: RoleIBMember,
|
||||
Ops: "rwdm",
|
||||
}, nil
|
||||
}
|
||||
|
||||
grants, err := getUserAccess(db, actorID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
16
lib/types.go
16
lib/types.go
|
|
@ -52,6 +52,7 @@ const (
|
|||
TypeComment = "comment"
|
||||
TypeOrganization = "organization"
|
||||
TypeDealOrg = "deal_org"
|
||||
TypeRequestTemplate = "request_template" // global template stored under TemplateProjectID
|
||||
)
|
||||
|
||||
// Stages
|
||||
|
|
@ -300,6 +301,21 @@ type RequestListData struct {
|
|||
VisibilityOrgID string `json:"visibility_org_id,omitempty"` // restrict visibility to this org (+ IB)
|
||||
}
|
||||
|
||||
// RequestTemplateData is the JSON structure packed into a request_template entry's Data field.
|
||||
type RequestTemplateData struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Items []RequestTemplateItem `json:"items"`
|
||||
}
|
||||
|
||||
// RequestTemplateItem is a single item in a request template.
|
||||
type RequestTemplateItem struct {
|
||||
Section string `json:"section"`
|
||||
ItemNumber string `json:"item_number,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// WorkstreamData is the JSON structure packed into a workstream entry's Data field.
|
||||
type WorkstreamData struct {
|
||||
Name string `json:"name"`
|
||||
|
|
|
|||
|
|
@ -24,6 +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="openTemplateModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">Use Template</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>
|
||||
|
|
@ -72,6 +73,30 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Picker Modal -->
|
||||
<div id="templateModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[#0d1f3c] rounded-xl w-full max-w-lg border border-white/[0.08]">
|
||||
<div class="flex items-center justify-between px-6 pt-5 pb-4 border-b border-white/[0.08]">
|
||||
<h2 class="text-lg font-semibold text-white">Use a Template</h2>
|
||||
<button onclick="closeTemplateModal()" class="text-[#94a3b8] hover:text-white text-2xl leading-none">×</button>
|
||||
</div>
|
||||
<div id="templateList" class="px-4 py-3 space-y-2 max-h-80 overflow-y-auto">
|
||||
<div class="text-sm text-[#94a3b8]">Loading templates...</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-white/[0.08]">
|
||||
<label class="block text-xs text-[#94a3b8] mb-1.5">List name (optional — uses template name by default)</label>
|
||||
<input type="text" id="templateListName" placeholder="Leave blank to use template name"
|
||||
class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] mb-4"
|
||||
onkeydown="if(event.key==='Enter')submitTemplateImport()">
|
||||
<div id="templateImportError" class="hidden mb-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="closeTemplateModal()" 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="templateImportBtn" onclick="submitTemplateImport()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Add to Deal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create List Modal -->
|
||||
<div id="createListModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[#0d1f3c] rounded-xl p-6 w-full max-w-md border border-white/[0.08]">
|
||||
|
|
@ -1354,32 +1379,10 @@
|
|||
const selectedTemplates = [...document.querySelectorAll('.tmpl-cb:checked')].map(cb => cb.value).filter(v => v !== 'none');
|
||||
for (const tmpl of selectedTemplates) {
|
||||
try {
|
||||
const tmplRes = await fetch('/templates/' + tmpl + '.json');
|
||||
if (!tmplRes.ok) continue;
|
||||
const tmplData = await tmplRes.json();
|
||||
// Create a request list for this template
|
||||
const listRes = await fetchAPI('/api/projects/' + newProjectID + '/requests', {
|
||||
await fetchAPI('/api/projects/' + newProjectID + '/requests/import-template', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: tmplData.name, project_id: newProjectID })
|
||||
body: JSON.stringify({ template_id: tmpl })
|
||||
});
|
||||
const listData = await listRes.json();
|
||||
if (!listRes.ok) continue;
|
||||
const listId = listData.entry_id;
|
||||
|
||||
// Create requests under this list
|
||||
for (const item of (tmplData.items || [])) {
|
||||
await fetchAPI('/api/projects/' + newProjectID + '/requests/new', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
parent_id: listId,
|
||||
title: item.title,
|
||||
item_number: item.item_number,
|
||||
section: item.section,
|
||||
priority: item.priority || 'medium',
|
||||
status: 'open',
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch(e) { console.error('Template import error:', tmpl, e); }
|
||||
}
|
||||
|
||||
|
|
@ -1911,6 +1914,112 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Template picker ----
|
||||
let selectedTemplateID = null;
|
||||
|
||||
async function openTemplateModal() {
|
||||
selectedTemplateID = null;
|
||||
document.getElementById('templateListName').value = '';
|
||||
document.getElementById('templateImportError').classList.add('hidden');
|
||||
document.getElementById('templateModal').style.display = 'flex';
|
||||
|
||||
const list = document.getElementById('templateList');
|
||||
list.innerHTML = '<div class="text-sm text-[#94a3b8]">Loading...</div>';
|
||||
|
||||
try {
|
||||
// Load saved DB templates
|
||||
const res = await fetchAPI('/api/templates');
|
||||
const saved = res.ok ? await res.json() : [];
|
||||
|
||||
// Built-in templates
|
||||
const builtins = [
|
||||
{ entry_id: 'core-8', name: 'Core Due Diligence', description: 'Essential 8 questions for any deal', item_count: 8, builtin: true },
|
||||
{ entry_id: 'comprehensive-100', name: 'Comprehensive Due Diligence', description: '100+ questions across all workstreams', item_count: 104, builtin: true },
|
||||
];
|
||||
|
||||
const all = [...builtins, ...saved];
|
||||
if (all.length === 0) {
|
||||
list.innerHTML = '<div class="text-sm text-[#94a3b8] py-4 text-center">No templates yet. Import a list and save it as a template.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = all.map(t => `
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg cursor-pointer hover:bg-white/[0.04] transition" style="border:1px solid var(--ds-bd)">
|
||||
<input type="radio" name="tmpl" value="${t.entry_id}" class="mt-1 accent-[#c9a84c]" onchange="selectedTemplateID='${t.entry_id}'">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">${escHtml(t.name)}</div>
|
||||
${t.description ? `<div class="text-xs mt-0.5" style="color:var(--ds-tx2)">${escHtml(t.description)}</div>` : ''}
|
||||
<div class="text-xs mt-0.5" style="color:var(--ds-tx3)">${t.item_count} items${t.builtin ? ' · Built-in' : ''}</div>
|
||||
</div>
|
||||
</label>`).join('');
|
||||
} catch(e) {
|
||||
list.innerHTML = '<div class="text-sm text-red-400">Failed to load templates.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeTemplateModal() {
|
||||
document.getElementById('templateModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitTemplateImport() {
|
||||
if (!selectedTemplateID) {
|
||||
document.getElementById('templateImportError').textContent = 'Please select a template.';
|
||||
document.getElementById('templateImportError').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('templateImportBtn');
|
||||
btn.disabled = true; btn.textContent = 'Importing...';
|
||||
document.getElementById('templateImportError').classList.add('hidden');
|
||||
try {
|
||||
const name = document.getElementById('templateListName').value.trim();
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/requests/import-template', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template_id: selectedTemplateID, name })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import failed');
|
||||
closeTemplateModal();
|
||||
await loadRequestTree();
|
||||
} catch(e) {
|
||||
document.getElementById('templateImportError').textContent = e.message;
|
||||
document.getElementById('templateImportError').classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Add to Deal';
|
||||
}
|
||||
}
|
||||
|
||||
// Save an imported list as a template (called after import)
|
||||
async function offerSaveTemplate(listId, name, prebuiltItems) {
|
||||
try {
|
||||
// Collect items from the just-imported list
|
||||
let items = prebuiltItems;
|
||||
if (!items) {
|
||||
// Build items from treeData
|
||||
const listItem = treeData.find(t => t.entry_id === listId);
|
||||
if (!listItem) return;
|
||||
items = [];
|
||||
const sections = treeData.filter(t => t.parent_id === listId && t.type === 'section');
|
||||
for (const sec of sections) {
|
||||
const reqs = treeData.filter(t => t.parent_id === sec.entry_id && t.type === 'request');
|
||||
for (const req of reqs) {
|
||||
const d = req.data || {};
|
||||
items.push({ section: (sec.data||{}).name || sec.entry_id, title: d.title || req.summary || '', priority: d.priority || 'medium', item_number: d.item_number || '' });
|
||||
}
|
||||
}
|
||||
// Also flat requests directly under list
|
||||
const flatReqs = treeData.filter(t => t.parent_id === listId && t.type === 'request');
|
||||
for (const req of flatReqs) {
|
||||
const d = req.data || {};
|
||||
items.push({ section: '', title: d.title || req.summary || '', priority: d.priority || 'medium', item_number: d.item_number || '' });
|
||||
}
|
||||
}
|
||||
await fetchAPI('/api/templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, items })
|
||||
});
|
||||
} catch(e) { console.error('Failed to save template:', e); }
|
||||
}
|
||||
|
||||
async function openImportModal() {
|
||||
document.getElementById('importModal').style.display='flex';
|
||||
document.getElementById('importFile').value='';
|
||||
|
|
@ -1950,7 +2059,15 @@
|
|||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import failed');
|
||||
closeImportModal();
|
||||
loadRequestTree();
|
||||
await loadRequestTree();
|
||||
// Offer to save as template
|
||||
if (data && (data.total_items > 0 || data.list_id)) {
|
||||
const listName = document.getElementById('importListName')?.value?.trim() || data.list_name || '';
|
||||
const tmplName = prompt('Save this as a reusable template for future deals?\nEnter a name or click Cancel to skip:', listName);
|
||||
if (tmplName && tmplName.trim()) {
|
||||
await offerSaveTemplate(data.list_id, tmplName.trim(), data.items_for_template);
|
||||
}
|
||||
}
|
||||
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue