From 912fd55bf3f51b9e8a5d6a2f4ba3eee3d042f21f Mon Sep 17 00:00:00 2001 From: James Date: Mon, 16 Mar 2026 19:22:39 -0400 Subject: [PATCH] feat: delete project, seller logos on cards, org reuse on scrape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- api/handlers.go | 276 ++++++++++++++++++++++++----- api/routes.go | 15 ++ lib/scrape.go | 18 +- portal/templates/app/project.html | 207 ++++++++++++++++++++-- portal/templates/app/projects.html | 46 +++-- 5 files changed, 484 insertions(+), 78 deletions(-) diff --git a/api/handlers.go b/api/handlers.go index 28f03b1..2495559 100644 --- a/api/handlers.go +++ b/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 } diff --git a/api/routes.go b/api/routes.go index 86f6817..d6a49db 100644 --- a/api/routes.go +++ b/api/routes.go @@ -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{ diff --git a/lib/scrape.go b/lib/scrape.go index 5a2f6d5..695adc7 100644 --- a/lib/scrape.go +++ b/lib/scrape.go @@ -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 } diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index 926118e..84238ee 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -277,7 +277,51 @@
- + +
+ + + + @@ -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(); + } } {{end}} diff --git a/portal/templates/app/projects.html b/portal/templates/app/projects.html index 52e23a0..31d3efa 100644 --- a/portal/templates/app/projects.html +++ b/portal/templates/app/projects.html @@ -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 = '
Failed to load projects.
'; @@ -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 `
-
${escHtml(name[0])}
+
${p.seller_logo ? `` : escHtml(name[0])}

${escHtml(name)}

@@ -84,7 +85,14 @@
- ${status} +
+ ${user.is_super_admin ? `` : ''} + ${status} +
${desc ? `

${escHtml(desc)}

` : '
'}
@@ -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();