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"})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {
|
||||
actorID := UserIDFromContext(r.Context())
|
||||
|
|
@ -611,7 +654,57 @@ func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
|
|||
if entries == nil {
|
||||
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
|
||||
|
|
@ -3122,51 +3215,77 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Step 1: Create organization entry
|
||||
orgID := uuid.New().String()
|
||||
orgData := lib.OrgData{
|
||||
Name: req.Name,
|
||||
Domains: req.Domains,
|
||||
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,
|
||||
// Step 1: Check if an organization with a matching domain already exists
|
||||
orgID := ""
|
||||
existingOrgs, _ := h.queryOrgsByType("")
|
||||
for _, org := range existingOrgs {
|
||||
orgMap := h.orgToMap(&org)
|
||||
if domains, ok := orgMap["domains"].([]string); ok {
|
||||
for _, d := range domains {
|
||||
for _, rd := range req.Domains {
|
||||
if strings.EqualFold(d, rd) {
|
||||
orgID = org.EntryID
|
||||
break
|
||||
}
|
||||
}
|
||||
if orgID != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
|
||||
return
|
||||
}
|
||||
orgSummary, _ := lib.Pack(orgKey, req.Name)
|
||||
orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))
|
||||
// Create new org if no match found
|
||||
if orgID == "" {
|
||||
orgID = uuid.New().String()
|
||||
orgData := lib.OrgData{
|
||||
Name: req.Name,
|
||||
Domains: req.Domains,
|
||||
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(
|
||||
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
||||
search_key, search_key2, summary, data, stage,
|
||||
assignee_id, return_to_id, origin_id,
|
||||
version, deleted_at, deleted_by, key_version,
|
||||
created_at, updated_at, created_by)
|
||||
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
||||
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
|
||||
orgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
|
||||
return
|
||||
}
|
||||
orgSummary, _ := lib.Pack(orgKey, req.Name)
|
||||
orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))
|
||||
|
||||
_, dbErr := h.DB.Conn.Exec(
|
||||
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,
|
||||
search_key, search_key2, summary, data, stage,
|
||||
assignee_id, return_to_id, origin_id,
|
||||
version, deleted_at, deleted_by, key_version,
|
||||
created_at, updated_at, created_by)
|
||||
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
||||
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
|
||||
|
|
@ -3187,7 +3306,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
|||
dealSummary, _ := lib.Pack(projKey, req.Name)
|
||||
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,
|
||||
search_key, search_key2, summary, data, stage,
|
||||
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,
|
||||
now, now, actorID,
|
||||
)
|
||||
if dbErr != nil {
|
||||
if dbErr2 != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal")
|
||||
return
|
||||
}
|
||||
|
|
@ -3229,6 +3348,67 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {
|
|||
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 == "" {
|
||||
ErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "LLM not configured")
|
||||
return
|
||||
|
|
@ -3237,7 +3417,11 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {
|
|||
result, err := lib.ScrapeOrgByEmail(h.Cfg.OpenRouterKey, req.Email)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
|||
r.Post("/projects", h.CreateProject)
|
||||
r.Get("/projects/{projectID}", h.GetProjectDetail)
|
||||
r.Get("/projects/{projectID}/detail", h.GetProjectDetail) // legacy alias
|
||||
r.Delete("/projects/{projectID}", h.DeleteProject)
|
||||
|
||||
// Workstreams
|
||||
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)
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dirs := []string{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package lib
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -10,6 +11,10 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
func decodeHTMLEntities(s string) string {
|
||||
return html.UnescapeString(s)
|
||||
}
|
||||
|
||||
// ScrapedOrg is the structured result from scraping an organization's website.
|
||||
type ScrapedOrg struct {
|
||||
Name string `json:"name"`
|
||||
|
|
@ -115,7 +120,7 @@ HTML:
|
|||
|
||||
RULES:
|
||||
- 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://...)
|
||||
- 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.
|
||||
|
|
@ -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:
|
||||
%s`, domain, domain, domain, domain, html)
|
||||
|
|
@ -176,6 +181,15 @@ HTML:
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,51 @@
|
|||
</div>
|
||||
<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="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>
|
||||
|
||||
|
|
@ -1205,6 +1249,7 @@
|
|||
requiredFields.forEach(id => document.getElementById(id).classList.remove('field-error'));
|
||||
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('addOrgSubmitBtn').textContent = 'Add to Deal';
|
||||
showAddOrgStep(1);
|
||||
document.getElementById('addOrgModal').classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('addOrgEmail').focus(), 100);
|
||||
|
|
@ -1222,6 +1267,116 @@
|
|||
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 ----
|
||||
const EMAIL_WHITELIST = ['@muskepo.com', '@jongsma.me'];
|
||||
function isWhitelisted(email) {
|
||||
|
|
@ -1259,7 +1414,13 @@
|
|||
document.getElementById('addOrgStep1').classList.toggle('hidden', n !== 1);
|
||||
document.getElementById('addOrgStep2').classList.toggle('hidden', n !== 2);
|
||||
document.getElementById('addOrgStep3').classList.toggle('hidden', n !== 3);
|
||||
document.getElementById('addOrgStep4').classList.toggle('hidden', n !== 4);
|
||||
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'];
|
||||
|
|
@ -1428,6 +1589,14 @@
|
|||
document.getElementById('selectAllPeople').checked = (checked === total);
|
||||
}
|
||||
|
||||
function proceedFromStep3() {
|
||||
if (_wizardMode) {
|
||||
showAddOrgStep(4);
|
||||
} else {
|
||||
submitAddOrg();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddOrg() {
|
||||
if (!validateStep2()) { showAddOrgStep(2); return; }
|
||||
const name = document.getElementById('orgName').value.trim();
|
||||
|
|
@ -1814,22 +1983,30 @@
|
|||
}
|
||||
|
||||
// ---- Init ----
|
||||
loadProject();
|
||||
const _params = new URLSearchParams(window.location.search);
|
||||
if (_params.get('tab') === 'orgs') {
|
||||
const orgsTab = document.querySelector('[onclick*="switchTab(\'orgs\'"]');
|
||||
if (orgsTab) switchTab('orgs', orgsTab);
|
||||
if (_params.get('addparty') === '1') {
|
||||
// New project flow — open Add Party with seller as default
|
||||
setTimeout(() => {
|
||||
openAddOrgModal();
|
||||
document.getElementById('orgRole').value = 'seller';
|
||||
document.getElementById('addOrgTitle').textContent = 'New Project';
|
||||
document.getElementById('addOrgHint').textContent = "Enter the email or domain of the company this project is for.";
|
||||
}, 300);
|
||||
}
|
||||
let _wizardMode = _params.get('wizard') === '1';
|
||||
|
||||
if (_wizardMode) {
|
||||
// New project wizard — don't load project (it doesn't exist yet)
|
||||
// Open the Add Party modal immediately in wizard mode
|
||||
setTimeout(() => {
|
||||
openAddOrgModal();
|
||||
document.getElementById('orgRole').value = 'seller';
|
||||
document.getElementById('addOrgTitle').textContent = 'New Project';
|
||||
document.getElementById('addOrgHint').textContent = "Enter the email or domain of the company this project is for.";
|
||||
document.getElementById('addOrgSubmitBtn').textContent = 'Next: Request Lists';
|
||||
}, 100);
|
||||
} 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>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
allProjects.sort((a, b) => (a._d.name || a.summary || '').localeCompare(b._d.name || b.summary || ''));
|
||||
renderProjects(allProjects);
|
||||
} catch(e) {
|
||||
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 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)">
|
||||
<div class="flex items-start justify-between mb-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"
|
||||
style="background:var(--ds-ac);color:var(--ds-act)">${escHtml(name[0])}</div>
|
||||
<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)">${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>
|
||||
<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">
|
||||
|
|
@ -84,7 +85,14 @@
|
|||
</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>
|
||||
${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)">
|
||||
|
|
@ -124,18 +132,26 @@
|
|||
renderProjects(filtered);
|
||||
}
|
||||
|
||||
async function openNewProject() {
|
||||
// Create a blank project, then redirect to it on the Parties tab
|
||||
function openNewProject() {
|
||||
// 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 {
|
||||
const res = await fetchAPI('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'New Project', description: '', status: 'draft' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create project');
|
||||
// Redirect to project with Parties tab + auto-open Add Party modal
|
||||
window.location.href = '/app/projects/' + (data.project_id || data.entry_id) + '?tab=orgs&addparty=1';
|
||||
} catch(e) { alert(e.message); }
|
||||
const res = await fetchAPI('/api/projects/' + projectId, { method: 'DELETE' });
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Failed'); }
|
||||
allProjects = allProjects.filter(p => p.entry_id !== projectId);
|
||||
if (allProjects.length === 0) {
|
||||
document.getElementById('projectGrid').classList.add('hidden');
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
} else {
|
||||
renderProjects(allProjects);
|
||||
}
|
||||
} catch(e) { alert('Failed to delete project: ' + e.message); }
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
|
|
|
|||
Loading…
Reference in New Issue