feat: delete project, seller logos on cards, org reuse on scrape
- Add DELETE /api/projects/{projectID} — super admin only, soft-deletes
project and all child entries
- Projects page: delete button on hover, alphabetical sort, seller org
logo on project cards
- Scrape endpoint checks for existing org by domain before scraping;
reuses existing org + members if found
- AddOrgToDeal reuses existing org entry when domain matches
- Clearer error message when website HTML exceeds LLM context limit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fe7543a0c8
commit
912fd55bf3
276
api/handlers.go
276
api/handlers.go
|
|
@ -199,6 +199,49 @@ func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteProject soft-deletes a project and all its entries. Super admin only.
|
||||||
|
func (h *Handlers) DeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
|
||||||
|
if !isSuperAdmin {
|
||||||
|
ErrorResponse(w, http.StatusForbidden, "access_denied", "Super admin only")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all entry IDs in this project
|
||||||
|
rows, err := h.DB.Conn.Query(
|
||||||
|
`SELECT entry_id FROM entries WHERE project_id = ? AND deleted_at IS NULL`,
|
||||||
|
projectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to query project entries")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entryIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err == nil {
|
||||||
|
entryIDs = append(entryIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entryIDs) == 0 {
|
||||||
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lib.EntryDelete(h.DB, actorID, projectID, entryIDs...); err != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to delete project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyTasks returns entries assigned to the current user.
|
// GetMyTasks returns entries assigned to the current user.
|
||||||
func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
actorID := UserIDFromContext(r.Context())
|
actorID := UserIDFromContext(r.Context())
|
||||||
|
|
@ -611,7 +654,57 @@ func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
entries = []lib.Entry{}
|
entries = []lib.Entry{}
|
||||||
}
|
}
|
||||||
JSONResponse(w, http.StatusOK, entries)
|
|
||||||
|
// Enrich each project with seller org logo
|
||||||
|
type projectWithLogo struct {
|
||||||
|
lib.Entry
|
||||||
|
SellerLogo string `json:"seller_logo,omitempty"`
|
||||||
|
SellerName string `json:"seller_name,omitempty"`
|
||||||
|
}
|
||||||
|
result := make([]projectWithLogo, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
result[i] = projectWithLogo{Entry: e}
|
||||||
|
// Find seller deal_org for this project
|
||||||
|
rows, err := h.DB.Conn.Query(
|
||||||
|
`SELECT data FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL`,
|
||||||
|
e.EntryID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, e.EntryID)
|
||||||
|
for rows.Next() {
|
||||||
|
var data []byte
|
||||||
|
if rows.Scan(&data) != nil || len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataText, err := lib.Unpack(projKey, data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var dod lib.DealOrgData
|
||||||
|
if json.Unmarshal([]byte(dataText), &dod) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dod.Role == "seller" {
|
||||||
|
// Fetch org entry for logo
|
||||||
|
org, err := lib.EntryByID(h.DB, h.Cfg, dod.OrgID)
|
||||||
|
if err == nil && org != nil {
|
||||||
|
orgDetails := h.orgToMap(org)
|
||||||
|
if logo, ok := orgDetails["logo"].(string); ok && logo != "" {
|
||||||
|
result[i].SellerLogo = logo
|
||||||
|
}
|
||||||
|
if name, ok := orgDetails["name"].(string); ok && name != "" {
|
||||||
|
result[i].SellerName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONResponse(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProject handles POST /api/projects
|
// CreateProject handles POST /api/projects
|
||||||
|
|
@ -3122,51 +3215,77 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
// Step 1: Create organization entry
|
// Step 1: Check if an organization with a matching domain already exists
|
||||||
orgID := uuid.New().String()
|
orgID := ""
|
||||||
orgData := lib.OrgData{
|
existingOrgs, _ := h.queryOrgsByType("")
|
||||||
Name: req.Name,
|
for _, org := range existingOrgs {
|
||||||
Domains: req.Domains,
|
orgMap := h.orgToMap(&org)
|
||||||
Role: req.Role,
|
if domains, ok := orgMap["domains"].([]string); ok {
|
||||||
Logo: req.Logo,
|
for _, d := range domains {
|
||||||
Website: req.Website,
|
for _, rd := range req.Domains {
|
||||||
Description: req.Description,
|
if strings.EqualFold(d, rd) {
|
||||||
Industry: req.Industry,
|
orgID = org.EntryID
|
||||||
Phone: req.Phone,
|
break
|
||||||
Fax: req.Fax,
|
}
|
||||||
Address: req.Address,
|
}
|
||||||
City: req.City,
|
if orgID != "" {
|
||||||
State: req.State,
|
break
|
||||||
Country: req.Country,
|
}
|
||||||
Founded: req.Founded,
|
}
|
||||||
LinkedIn: req.LinkedIn,
|
}
|
||||||
|
if orgID != "" {
|
||||||
|
log.Printf("reusing existing org %s for domain %v", orgID, req.Domains)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
orgDataJSON, _ := json.Marshal(orgData)
|
|
||||||
|
|
||||||
orgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)
|
// Create new org if no match found
|
||||||
if err != nil {
|
if orgID == "" {
|
||||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
|
orgID = uuid.New().String()
|
||||||
return
|
orgData := lib.OrgData{
|
||||||
}
|
Name: req.Name,
|
||||||
orgSummary, _ := lib.Pack(orgKey, req.Name)
|
Domains: req.Domains,
|
||||||
orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))
|
Role: req.Role,
|
||||||
|
Logo: req.Logo,
|
||||||
|
Website: req.Website,
|
||||||
|
Description: req.Description,
|
||||||
|
Industry: req.Industry,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Fax: req.Fax,
|
||||||
|
Address: req.Address,
|
||||||
|
City: req.City,
|
||||||
|
State: req.State,
|
||||||
|
Country: req.Country,
|
||||||
|
Founded: req.Founded,
|
||||||
|
LinkedIn: req.LinkedIn,
|
||||||
|
}
|
||||||
|
orgDataJSON, _ := json.Marshal(orgData)
|
||||||
|
|
||||||
_, dbErr := h.DB.Conn.Exec(
|
orgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)
|
||||||
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
if err != nil {
|
||||||
search_key, search_key2, summary, data, stage,
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
|
||||||
assignee_id, return_to_id, origin_id,
|
return
|
||||||
version, deleted_at, deleted_by, key_version,
|
}
|
||||||
created_at, updated_at, created_by)
|
orgSummary, _ := lib.Pack(orgKey, req.Name)
|
||||||
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))
|
||||||
orgID, orgID, "", lib.TypeOrganization, 0, 0,
|
|
||||||
nil, nil, orgSummary, orgDataPacked, lib.StageDataroom,
|
_, dbErr := h.DB.Conn.Exec(
|
||||||
"", "", "",
|
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
||||||
1, nil, nil, 1,
|
search_key, search_key2, summary, data, stage,
|
||||||
now, now, actorID,
|
assignee_id, return_to_id, origin_id,
|
||||||
)
|
version, deleted_at, deleted_by, key_version,
|
||||||
if dbErr != nil {
|
created_at, updated_at, created_by)
|
||||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization")
|
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
||||||
return
|
orgID, orgID, "", lib.TypeOrganization, 0, 0,
|
||||||
|
nil, nil, orgSummary, orgDataPacked, lib.StageDataroom,
|
||||||
|
"", "", "",
|
||||||
|
1, nil, nil, 1,
|
||||||
|
now, now, actorID,
|
||||||
|
)
|
||||||
|
if dbErr != nil {
|
||||||
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create deal_org entry linking org to project
|
// Step 2: Create deal_org entry linking org to project
|
||||||
|
|
@ -3187,7 +3306,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
dealSummary, _ := lib.Pack(projKey, req.Name)
|
dealSummary, _ := lib.Pack(projKey, req.Name)
|
||||||
dealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON))
|
dealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON))
|
||||||
|
|
||||||
_, dbErr = h.DB.Conn.Exec(
|
_, dbErr2 := h.DB.Conn.Exec(
|
||||||
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
||||||
search_key, search_key2, summary, data, stage,
|
search_key, search_key2, summary, data, stage,
|
||||||
assignee_id, return_to_id, origin_id,
|
assignee_id, return_to_id, origin_id,
|
||||||
|
|
@ -3200,7 +3319,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
1, nil, nil, 1,
|
1, nil, nil, 1,
|
||||||
now, now, actorID,
|
now, now, actorID,
|
||||||
)
|
)
|
||||||
if dbErr != nil {
|
if dbErr2 != nil {
|
||||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal")
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -3229,6 +3348,67 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract domain from email
|
||||||
|
domain := strings.SplitN(req.Email, "@", 2)[1]
|
||||||
|
|
||||||
|
// Check if we already have an org with this domain
|
||||||
|
existingOrgs, _ := h.queryOrgsByType("")
|
||||||
|
for _, org := range existingOrgs {
|
||||||
|
orgMap := h.orgToMap(&org)
|
||||||
|
if domains, ok := orgMap["domains"].([]string); ok {
|
||||||
|
for _, d := range domains {
|
||||||
|
if strings.EqualFold(d, domain) {
|
||||||
|
// Return existing org data in scrape format
|
||||||
|
existing := &lib.ScrapedOrg{
|
||||||
|
Name: fmt.Sprintf("%v", orgMap["name"]),
|
||||||
|
Domain: domain,
|
||||||
|
Logo: fmt.Sprintf("%v", orgMap["logo"]),
|
||||||
|
Description: fmt.Sprintf("%v", orgMap["description"]),
|
||||||
|
Industry: fmt.Sprintf("%v", orgMap["industry"]),
|
||||||
|
Website: fmt.Sprintf("%v", orgMap["website"]),
|
||||||
|
}
|
||||||
|
// Get members from deal_orgs that reference this org
|
||||||
|
rows, err := h.DB.Conn.Query(
|
||||||
|
`SELECT data, project_id FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var data []byte
|
||||||
|
var pid string
|
||||||
|
if rows.Scan(&data, &pid) != nil || len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, pid)
|
||||||
|
dataText, err := lib.Unpack(projKey, data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var dod lib.DealOrgData
|
||||||
|
if json.Unmarshal([]byte(dataText), &dod) == nil && dod.OrgID == org.EntryID {
|
||||||
|
for _, m := range dod.Members {
|
||||||
|
existing.People = append(existing.People, lib.ScrapedPerson{
|
||||||
|
Name: m.Name,
|
||||||
|
Email: m.Email,
|
||||||
|
Title: m.Title,
|
||||||
|
Phone: m.Phone,
|
||||||
|
Photo: m.Photo,
|
||||||
|
Bio: m.Bio,
|
||||||
|
LinkedIn: m.LinkedIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("returning existing org %s for domain %s", org.EntryID, domain)
|
||||||
|
JSONResponse(w, http.StatusOK, existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if h.Cfg.OpenRouterKey == "" {
|
if h.Cfg.OpenRouterKey == "" {
|
||||||
ErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "LLM not configured")
|
ErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "LLM not configured")
|
||||||
return
|
return
|
||||||
|
|
@ -3237,7 +3417,11 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
result, err := lib.ScrapeOrgByEmail(h.Cfg.OpenRouterKey, req.Email)
|
result, err := lib.ScrapeOrgByEmail(h.Cfg.OpenRouterKey, req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("scrape org error for %s: %v", req.Email, err)
|
log.Printf("scrape org error for %s: %v", req.Email, err)
|
||||||
ErrorResponse(w, http.StatusUnprocessableEntity, "scrape_failed", "Could not scrape organization website")
|
msg := "Could not scrape organization website"
|
||||||
|
if strings.Contains(err.Error(), "context length") || strings.Contains(err.Error(), "too many tokens") {
|
||||||
|
msg = "Website is too large to analyze automatically"
|
||||||
|
}
|
||||||
|
ErrorResponse(w, http.StatusUnprocessableEntity, "scrape_failed", msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
||||||
r.Post("/projects", h.CreateProject)
|
r.Post("/projects", h.CreateProject)
|
||||||
r.Get("/projects/{projectID}", h.GetProjectDetail)
|
r.Get("/projects/{projectID}", h.GetProjectDetail)
|
||||||
r.Get("/projects/{projectID}/detail", h.GetProjectDetail) // legacy alias
|
r.Get("/projects/{projectID}/detail", h.GetProjectDetail) // legacy alias
|
||||||
|
r.Delete("/projects/{projectID}", h.DeleteProject)
|
||||||
|
|
||||||
// Workstreams
|
// Workstreams
|
||||||
r.Post("/projects/{projectID}/workstreams", h.CreateWorkstream)
|
r.Post("/projects/{projectID}/workstreams", h.CreateWorkstream)
|
||||||
|
|
@ -148,6 +149,20 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request list templates (JSON)
|
||||||
|
r.Handle("/templates/*", http.StripPrefix("/templates/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dirs := []string{"templates", "/opt/dealspace/templates"}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
fp := dir + "/" + r.URL.Path
|
||||||
|
if _, err := os.Stat(fp); err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
http.ServeFile(w, r, fp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})))
|
||||||
|
|
||||||
// Static files (portal + website CSS/JS)
|
// Static files (portal + website CSS/JS)
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
dirs := []string{
|
dirs := []string{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package lib
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -10,6 +11,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func decodeHTMLEntities(s string) string {
|
||||||
|
return html.UnescapeString(s)
|
||||||
|
}
|
||||||
|
|
||||||
// ScrapedOrg is the structured result from scraping an organization's website.
|
// ScrapedOrg is the structured result from scraping an organization's website.
|
||||||
type ScrapedOrg struct {
|
type ScrapedOrg struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -115,7 +120,7 @@ HTML:
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Extract EVERY person mentioned — do not skip anyone
|
- Extract EVERY person mentioned — do not skip anyone
|
||||||
- Every person MUST have a "title" (job title / role). Look at section headings, CSS classes, surrounding text to determine titles. Common patterns: "Co-Founder", "Partner", "Managing Director", "Principal", "Investment Professional", "Operating Partner", "Operations Manager", "Finance & Operations", "Analyst", "Associate". If a person is under a heading like "Investment Professionals", their title is "Investment Professional". Never use generic "Team Member".
|
- Every person should have a "title" (job title / role) if one exists. Look at section headings, CSS classes, surrounding text. Common patterns: "Co-Founder", "Partner", "Managing Director", "Principal", "Investment Professional", "Operating Partner", "Operations Manager", "Finance & Operations", "Analyst", "Associate". If a person is under a heading like "Investment Professionals", their title is "Investment Professional". If no title can be determined, leave the title field empty — NEVER use generic placeholders like "Team Member" or "Staff".
|
||||||
- Photo/logo URLs must be fully qualified (https://...)
|
- Photo/logo URLs must be fully qualified (https://...)
|
||||||
- Logo: find the company logo image — look for img tags in the header, navbar, or footer with "logo" in the src/alt/class. Return the full absolute URL.
|
- Logo: find the company logo image — look for img tags in the header, navbar, or footer with "logo" in the src/alt/class. Return the full absolute URL.
|
||||||
- Address: put ONLY the street address in "address" (e.g. "2151 Central Avenue"). Put city, state, country in their own fields. Do NOT combine them.
|
- Address: put ONLY the street address in "address" (e.g. "2151 Central Avenue"). Put city, state, country in their own fields. Do NOT combine them.
|
||||||
|
|
@ -152,7 +157,7 @@ Return a single JSON object:
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Return ONLY valid JSON — no markdown, no explanation.
|
Return ONLY valid JSON — no markdown, no explanation. All text values must be clean plain text — decode any HTML entities (e.g. ’ → ', & → &).
|
||||||
|
|
||||||
HTML:
|
HTML:
|
||||||
%s`, domain, domain, domain, domain, html)
|
%s`, domain, domain, domain, domain, html)
|
||||||
|
|
@ -176,6 +181,15 @@ HTML:
|
||||||
result.Website = "https://" + domain
|
result.Website = "https://" + domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean HTML entities from text fields
|
||||||
|
result.Name = decodeHTMLEntities(result.Name)
|
||||||
|
result.Description = decodeHTMLEntities(result.Description)
|
||||||
|
for i := range result.People {
|
||||||
|
result.People[i].Name = decodeHTMLEntities(result.People[i].Name)
|
||||||
|
result.People[i].Title = decodeHTMLEntities(result.People[i].Title)
|
||||||
|
result.People[i].Bio = decodeHTMLEntities(result.People[i].Bio)
|
||||||
|
}
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,51 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button onclick="showAddOrgStep(2)" class="px-4 py-2 text-[#b0bec5] hover:text-white text-sm transition">Back</button>
|
<button onclick="showAddOrgStep(2)" class="px-4 py-2 text-[#b0bec5] hover:text-white text-sm transition">Back</button>
|
||||||
<button onclick="submitAddOrg()" id="addOrgSubmitBtn" class="px-5 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Add to Deal</button>
|
<button onclick="proceedFromStep3()" id="addOrgSubmitBtn" class="px-5 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Add to Deal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Request List Templates (wizard mode only) -->
|
||||||
|
<div id="addOrgStep4" class="hidden p-6">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Request Lists</h2>
|
||||||
|
<button onclick="closeAddOrgModal()" class="text-[#b0bec5] hover:text-white transition text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[#b0bec5] text-sm mb-4">Choose which request list templates to include in this project.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs mb-1" style="color:var(--ds-tx2)">Project name</label>
|
||||||
|
<input type="text" id="wizProjectName" class="w-full px-3 py-2 rounded-lg text-sm focus:outline-none mb-4"
|
||||||
|
style="background:var(--ds-inp);border:1px solid var(--ds-bd);color:var(--ds-tx)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-6" id="templateList">
|
||||||
|
<label class="flex items-start gap-3 p-4 rounded-xl cursor-pointer transition" style="background:var(--ds-hv);border:1px solid var(--ds-bd)">
|
||||||
|
<input type="checkbox" class="tmpl-cb mt-0.5 accent-[#c9a84c]" value="core-8" checked onchange="tmplChanged(this)">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold" style="color:var(--ds-tx)">Core Due Diligence</div>
|
||||||
|
<div class="text-xs mt-0.5" style="color:var(--ds-tx2)">8 essential items — financials, legal, commercial, HR, tax. The minimum every deal needs.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-4 rounded-xl cursor-pointer transition" style="background:var(--ds-hv);border:1px solid var(--ds-bd)">
|
||||||
|
<input type="checkbox" class="tmpl-cb mt-0.5 accent-[#c9a84c]" value="comprehensive-100" onchange="tmplChanged(this)">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold" style="color:var(--ds-tx)">Comprehensive Due Diligence</div>
|
||||||
|
<div class="text-xs mt-0.5" style="color:var(--ds-tx2)">104 items across 18 sections — financial, legal, tax, commercial, HR, IP, IT, environmental, insurance. Full-scope M&A checklist.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-4 rounded-xl cursor-pointer transition" style="background:var(--ds-hv);border:1px solid var(--ds-bd)">
|
||||||
|
<input type="checkbox" class="tmpl-cb mt-0.5 accent-[#c9a84c]" value="none" onchange="tmplChanged(this)">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold" style="color:var(--ds-tx)">Start empty</div>
|
||||||
|
<div class="text-xs mt-0.5" style="color:var(--ds-tx2)">No request lists — import or create your own later.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button onclick="showAddOrgStep(3)" class="px-4 py-2 text-[#b0bec5] hover:text-white text-sm transition">Back</button>
|
||||||
|
<button onclick="createNewProject()" id="wizCreateBtn" class="px-6 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create Project</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1205,6 +1249,7 @@
|
||||||
requiredFields.forEach(id => document.getElementById(id).classList.remove('field-error'));
|
requiredFields.forEach(id => document.getElementById(id).classList.remove('field-error'));
|
||||||
document.getElementById('addOrgTitle').textContent = 'Add Party';
|
document.getElementById('addOrgTitle').textContent = 'Add Party';
|
||||||
document.getElementById('addOrgHint').textContent = "Enter an email address or domain name \u2014 we'll look up the organization automatically.";
|
document.getElementById('addOrgHint').textContent = "Enter an email address or domain name \u2014 we'll look up the organization automatically.";
|
||||||
|
document.getElementById('addOrgSubmitBtn').textContent = 'Add to Deal';
|
||||||
showAddOrgStep(1);
|
showAddOrgStep(1);
|
||||||
document.getElementById('addOrgModal').classList.remove('hidden');
|
document.getElementById('addOrgModal').classList.remove('hidden');
|
||||||
setTimeout(() => document.getElementById('addOrgEmail').focus(), 100);
|
setTimeout(() => document.getElementById('addOrgEmail').focus(), 100);
|
||||||
|
|
@ -1222,6 +1267,116 @@
|
||||||
document.getElementById('projectMenu')?.classList.add('hidden');
|
document.getElementById('projectMenu')?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function tmplChanged(el) {
|
||||||
|
if (el.value === 'none' && el.checked) {
|
||||||
|
// "Start empty" unchecks the others
|
||||||
|
document.querySelectorAll('.tmpl-cb').forEach(cb => { if (cb.value !== 'none') cb.checked = false; });
|
||||||
|
} else if (el.value !== 'none' && el.checked) {
|
||||||
|
// Selecting a template unchecks "Start empty"
|
||||||
|
document.querySelector('.tmpl-cb[value="none"]').checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- New Project Wizard: Create Everything ----
|
||||||
|
async function createNewProject() {
|
||||||
|
const btn = document.getElementById('wizCreateBtn');
|
||||||
|
const projectName = document.getElementById('wizProjectName').value.trim();
|
||||||
|
if (!projectName) { alert('Project name is required.'); return; }
|
||||||
|
if (!validateStep2()) { showAddOrgStep(2); return; }
|
||||||
|
|
||||||
|
btn.disabled = true; btn.textContent = 'Creating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create project
|
||||||
|
const projRes = await fetchAPI('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: projectName, deal_type: 'sell_side' })
|
||||||
|
});
|
||||||
|
const projData = await projRes.json();
|
||||||
|
if (!projRes.ok) throw new Error(projData.error || 'Failed to create project');
|
||||||
|
const newProjectID = projData.project_id || projData.entry_id;
|
||||||
|
|
||||||
|
// 2. Add org + members to project
|
||||||
|
const name = document.getElementById('orgName').value.trim();
|
||||||
|
const role = document.getElementById('orgRole').value;
|
||||||
|
const domain = scrapedData?.domain || '';
|
||||||
|
const people = scrapedData?.people || [];
|
||||||
|
const selectedMembers = [];
|
||||||
|
document.querySelectorAll('.person-cb:checked').forEach(cb => {
|
||||||
|
const p = people[parseInt(cb.dataset.idx)];
|
||||||
|
if (p) selectedMembers.push({ name: p.name, email: p.email || '', title: p.title || '', phone: p.phone || '', photo: p.photo || '', bio: p.bio || '', linkedin: p.linkedin || '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgRes = await fetchAPI('/api/projects/' + newProjectID + '/orgs/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
domains: domain ? [domain] : [],
|
||||||
|
role,
|
||||||
|
logo: scrapedData?.logo || '',
|
||||||
|
website: document.getElementById('orgWebsite').value,
|
||||||
|
description: document.getElementById('orgDesc').value,
|
||||||
|
industry: document.getElementById('orgIndustry').value,
|
||||||
|
phone: document.getElementById('orgPhone').value,
|
||||||
|
address: document.getElementById('orgAddress').value,
|
||||||
|
city: document.getElementById('orgCity').value,
|
||||||
|
state: document.getElementById('orgState').value,
|
||||||
|
founded: document.getElementById('orgFounded').value,
|
||||||
|
linkedin: document.getElementById('orgLinkedIn').value,
|
||||||
|
members: selectedMembers,
|
||||||
|
domain_lock: true,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!orgRes.ok) { const e = await orgRes.json(); throw new Error(e.error || 'Failed to add org'); }
|
||||||
|
|
||||||
|
// 3. Import selected request list templates
|
||||||
|
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', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: tmplData.name, project_id: newProjectID })
|
||||||
|
});
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Show email preview and redirect
|
||||||
|
closeAddOrgModal();
|
||||||
|
if (selectedMembers.length > 0) {
|
||||||
|
showEmailPreview(selectedMembers, name);
|
||||||
|
}
|
||||||
|
// Redirect to the new project
|
||||||
|
window.location.href = '/app/projects/' + newProjectID;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
alert(e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Create Project';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Email Preview ----
|
// ---- Email Preview ----
|
||||||
const EMAIL_WHITELIST = ['@muskepo.com', '@jongsma.me'];
|
const EMAIL_WHITELIST = ['@muskepo.com', '@jongsma.me'];
|
||||||
function isWhitelisted(email) {
|
function isWhitelisted(email) {
|
||||||
|
|
@ -1259,7 +1414,13 @@
|
||||||
document.getElementById('addOrgStep1').classList.toggle('hidden', n !== 1);
|
document.getElementById('addOrgStep1').classList.toggle('hidden', n !== 1);
|
||||||
document.getElementById('addOrgStep2').classList.toggle('hidden', n !== 2);
|
document.getElementById('addOrgStep2').classList.toggle('hidden', n !== 2);
|
||||||
document.getElementById('addOrgStep3').classList.toggle('hidden', n !== 3);
|
document.getElementById('addOrgStep3').classList.toggle('hidden', n !== 3);
|
||||||
|
document.getElementById('addOrgStep4').classList.toggle('hidden', n !== 4);
|
||||||
if (n === 3) renderPeople();
|
if (n === 3) renderPeople();
|
||||||
|
if (n === 4) {
|
||||||
|
// Pre-fill project name from org name
|
||||||
|
const orgName = document.getElementById('orgName').value.trim();
|
||||||
|
document.getElementById('wizProjectName').value = orgName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredFields = ['orgName', 'orgRole'];
|
const requiredFields = ['orgName', 'orgRole'];
|
||||||
|
|
@ -1428,6 +1589,14 @@
|
||||||
document.getElementById('selectAllPeople').checked = (checked === total);
|
document.getElementById('selectAllPeople').checked = (checked === total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proceedFromStep3() {
|
||||||
|
if (_wizardMode) {
|
||||||
|
showAddOrgStep(4);
|
||||||
|
} else {
|
||||||
|
submitAddOrg();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitAddOrg() {
|
async function submitAddOrg() {
|
||||||
if (!validateStep2()) { showAddOrgStep(2); return; }
|
if (!validateStep2()) { showAddOrgStep(2); return; }
|
||||||
const name = document.getElementById('orgName').value.trim();
|
const name = document.getElementById('orgName').value.trim();
|
||||||
|
|
@ -1814,22 +1983,30 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init ----
|
// ---- Init ----
|
||||||
loadProject();
|
|
||||||
const _params = new URLSearchParams(window.location.search);
|
const _params = new URLSearchParams(window.location.search);
|
||||||
if (_params.get('tab') === 'orgs') {
|
let _wizardMode = _params.get('wizard') === '1';
|
||||||
const orgsTab = document.querySelector('[onclick*="switchTab(\'orgs\'"]');
|
|
||||||
if (orgsTab) switchTab('orgs', orgsTab);
|
if (_wizardMode) {
|
||||||
if (_params.get('addparty') === '1') {
|
// New project wizard — don't load project (it doesn't exist yet)
|
||||||
// New project flow — open Add Party with seller as default
|
// Open the Add Party modal immediately in wizard mode
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
openAddOrgModal();
|
openAddOrgModal();
|
||||||
document.getElementById('orgRole').value = 'seller';
|
document.getElementById('orgRole').value = 'seller';
|
||||||
document.getElementById('addOrgTitle').textContent = 'New Project';
|
document.getElementById('addOrgTitle').textContent = 'New Project';
|
||||||
document.getElementById('addOrgHint').textContent = "Enter the email or domain of the company this project is for.";
|
document.getElementById('addOrgHint').textContent = "Enter the email or domain of the company this project is for.";
|
||||||
}, 300);
|
document.getElementById('addOrgSubmitBtn').textContent = 'Next: Request Lists';
|
||||||
}
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
loadRequestTree();
|
loadProject();
|
||||||
|
if (_params.get('tab') === 'orgs') {
|
||||||
|
const orgsTab = document.querySelector('[onclick*="switchTab(\'orgs\'"]');
|
||||||
|
if (orgsTab) switchTab('orgs', orgsTab);
|
||||||
|
if (_params.get('addparty') === '1') {
|
||||||
|
setTimeout(() => openAddOrgModal(), 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadRequestTree();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allProjects.sort((a, b) => (a._d.name || a.summary || '').localeCompare(b._d.name || b.summary || ''));
|
||||||
renderProjects(allProjects);
|
renderProjects(allProjects);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-full">Failed to load projects.</div>';
|
document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-full">Failed to load projects.</div>';
|
||||||
|
|
@ -69,12 +70,12 @@
|
||||||
const dealType = dealTypeLabels[d.deal_type] || '';
|
const dealType = dealTypeLabels[d.deal_type] || '';
|
||||||
const date = new Date(p.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
const date = new Date(p.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
return `<a href="/app/projects/${p.entry_id}" class="group block rounded-xl p-6 transition hover:scale-[1.01]"
|
return `<a href="/app/projects/${p.entry_id}" class="group block rounded-xl p-6 transition hover:scale-[1.01] relative"
|
||||||
style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
|
style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold shrink-0"
|
<div class="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold shrink-0 overflow-hidden"
|
||||||
style="background:var(--ds-ac);color:var(--ds-act)">${escHtml(name[0])}</div>
|
style="background:var(--ds-ac);color:var(--ds-act)">${p.seller_logo ? `<img src="${escHtml(p.seller_logo)}" class="w-full h-full object-contain" onerror="this.replaceWith(document.createTextNode('${escHtml(name[0])}'))">` : escHtml(name[0])}</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-base leading-tight group-hover:text-[#c9a84c] transition" style="color:var(--ds-tx)">${escHtml(name)}</h3>
|
<h3 class="font-semibold text-base leading-tight group-hover:text-[#c9a84c] transition" style="color:var(--ds-tx)">${escHtml(name)}</h3>
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
|
|
@ -84,7 +85,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
${user.is_super_admin ? `<button onclick="event.preventDefault();event.stopPropagation();confirmDeleteProject('${p.entry_id}')"
|
||||||
|
class="w-7 h-7 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition hover:bg-red-500/20 hover:text-red-400"
|
||||||
|
style="color:var(--ds-tx3)" title="Delete project">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>` : ''}
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${desc ? `<p class="text-sm mb-4 line-clamp-2" style="color:var(--ds-tx2)">${escHtml(desc)}</p>` : '<div class="mb-4"></div>'}
|
${desc ? `<p class="text-sm mb-4 line-clamp-2" style="color:var(--ds-tx2)">${escHtml(desc)}</p>` : '<div class="mb-4"></div>'}
|
||||||
<div class="flex items-center gap-4 text-xs" style="color:var(--ds-tx2)">
|
<div class="flex items-center gap-4 text-xs" style="color:var(--ds-tx2)">
|
||||||
|
|
@ -124,18 +132,26 @@
|
||||||
renderProjects(filtered);
|
renderProjects(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNewProject() {
|
function openNewProject() {
|
||||||
// Create a blank project, then redirect to it on the Parties tab
|
// Redirect to a special "new" route that opens the full wizard
|
||||||
|
window.location.href = '/app/projects/new?wizard=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteProject(projectId) {
|
||||||
|
const p = allProjects.find(x => x.entry_id === projectId);
|
||||||
|
const name = p ? (p._d.name || p.summary || 'Untitled') : 'this project';
|
||||||
|
if (!confirm('Delete "' + name + '"?\n\nThis will delete the project and all its data. This cannot be undone.')) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI('/api/projects', {
|
const res = await fetchAPI('/api/projects/' + projectId, { method: 'DELETE' });
|
||||||
method: 'POST',
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Failed'); }
|
||||||
body: JSON.stringify({ name: 'New Project', description: '', status: 'draft' })
|
allProjects = allProjects.filter(p => p.entry_id !== projectId);
|
||||||
});
|
if (allProjects.length === 0) {
|
||||||
const data = await res.json();
|
document.getElementById('projectGrid').classList.add('hidden');
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to create project');
|
document.getElementById('emptyState').classList.remove('hidden');
|
||||||
// Redirect to project with Parties tab + auto-open Add Party modal
|
} else {
|
||||||
window.location.href = '/app/projects/' + (data.project_id || data.entry_id) + '?tab=orgs&addparty=1';
|
renderProjects(allProjects);
|
||||||
} catch(e) { alert(e.message); }
|
}
|
||||||
|
} catch(e) { alert('Failed to delete project: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue