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:
James 2026-03-16 19:22:39 -04:00
parent fe7543a0c8
commit 912fd55bf3
5 changed files with 484 additions and 78 deletions

View File

@ -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,8 +3215,33 @@ 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 := ""
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
}
}
// Create new org if no match found
if orgID == "" {
orgID = uuid.New().String()
orgData := lib.OrgData{ orgData := lib.OrgData{
Name: req.Name, Name: req.Name,
Domains: req.Domains, Domains: req.Domains,
@ -3168,6 +3286,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization") ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization")
return return
} }
}
// Step 2: Create deal_org entry linking org to project // Step 2: Create deal_org entry linking org to project
dealOrgID := uuid.New().String() dealOrgID := uuid.New().String()
@ -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
} }

View File

@ -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{

View File

@ -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. &#8217; ', &amp; &).
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
} }

View File

@ -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">&times;</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 {
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 { } else {
loadRequestTree(); loadRequestTree();
} }
}
</script> </script>
{{end}} {{end}}

View File

@ -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();