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
|
// ListRequests handles GET /api/projects/{projectID}/requests
|
||||||
// Returns all request entries for a project, sorted by section + item_number
|
// 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) {
|
func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) {
|
||||||
actorID := UserIDFromContext(r.Context())
|
actorID := UserIDFromContext(r.Context())
|
||||||
projectID := chi.URLParam(r, "projectID")
|
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}/requests/new", h.CreateRequest)
|
||||||
r.Post("/projects/{projectID}/sections", h.CreateSection)
|
r.Post("/projects/{projectID}/sections", h.CreateSection)
|
||||||
r.Post("/projects/{projectID}/requests/import", h.ImportRequests)
|
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
|
// Request detail
|
||||||
r.Get("/requests/{requestID}", h.GetRequestDetail)
|
r.Get("/requests/{requestID}", h.GetRequestDetail)
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,25 @@ func unpackEntry(cfg *Config, e *Entry) error {
|
||||||
return nil
|
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
|
// User operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1102,6 +1121,45 @@ func OAuthTokenRevoke(db *DB, tokenStr string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSessionTestRole updates the test_role on a session (super-admin only).
|
// 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 {
|
func SetSessionTestRole(db *DB, sessionID, role string) error {
|
||||||
_, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID)
|
_, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
14
lib/rbac.go
14
lib/rbac.go
|
|
@ -11,6 +11,10 @@ var (
|
||||||
ErrInsufficientOps = errors.New("insufficient permissions")
|
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.
|
// 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).
|
// op is one of "r", "w", "d", "m" (read, write, delete, manage).
|
||||||
// Returns the matching Access grant or an error.
|
// Returns the matching Access grant or an error.
|
||||||
|
|
@ -29,6 +33,16 @@ func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op stri
|
||||||
}, nil
|
}, 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)
|
grants, err := getUserAccess(db, actorID, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
34
lib/types.go
34
lib/types.go
|
|
@ -43,15 +43,16 @@ type Entry struct {
|
||||||
|
|
||||||
// Entry types
|
// Entry types
|
||||||
const (
|
const (
|
||||||
TypeProject = "project"
|
TypeProject = "project"
|
||||||
TypeWorkstream = "workstream"
|
TypeWorkstream = "workstream"
|
||||||
TypeRequestList = "request_list"
|
TypeRequestList = "request_list"
|
||||||
TypeRequest = "request"
|
TypeRequest = "request"
|
||||||
TypeAnswer = "answer"
|
TypeAnswer = "answer"
|
||||||
TypeSection = "section"
|
TypeSection = "section"
|
||||||
TypeComment = "comment"
|
TypeComment = "comment"
|
||||||
TypeOrganization = "organization"
|
TypeOrganization = "organization"
|
||||||
TypeDealOrg = "deal_org"
|
TypeDealOrg = "deal_org"
|
||||||
|
TypeRequestTemplate = "request_template" // global template stored under TemplateProjectID
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stages
|
// Stages
|
||||||
|
|
@ -300,6 +301,21 @@ type RequestListData struct {
|
||||||
VisibilityOrgID string `json:"visibility_org_id,omitempty"` // restrict visibility to this org (+ IB)
|
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.
|
// WorkstreamData is the JSON structure packed into a workstream entry's Data field.
|
||||||
type WorkstreamData struct {
|
type WorkstreamData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
||||||
|
|
@ -24,6 +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="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 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>
|
||||||
|
|
@ -72,6 +73,30 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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 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]">
|
<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');
|
const selectedTemplates = [...document.querySelectorAll('.tmpl-cb:checked')].map(cb => cb.value).filter(v => v !== 'none');
|
||||||
for (const tmpl of selectedTemplates) {
|
for (const tmpl of selectedTemplates) {
|
||||||
try {
|
try {
|
||||||
const tmplRes = await fetch('/templates/' + tmpl + '.json');
|
await fetchAPI('/api/projects/' + newProjectID + '/requests/import-template', {
|
||||||
if (!tmplRes.ok) continue;
|
|
||||||
const tmplData = await tmplRes.json();
|
|
||||||
// Create a request list for this template
|
|
||||||
const listRes = await fetchAPI('/api/projects/' + newProjectID + '/requests', {
|
|
||||||
method: 'POST',
|
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); }
|
} 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() {
|
async function openImportModal() {
|
||||||
document.getElementById('importModal').style.display='flex';
|
document.getElementById('importModal').style.display='flex';
|
||||||
document.getElementById('importFile').value='';
|
document.getElementById('importFile').value='';
|
||||||
|
|
@ -1950,7 +2059,15 @@
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Import failed');
|
if (!res.ok) throw new Error(data.error || 'Import failed');
|
||||||
closeImportModal();
|
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'); }
|
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
|
||||||
finally { btn.disabled = false; btn.textContent = 'Import'; }
|
finally { btn.disabled = false; btn.textContent = 'Import'; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue