feat: global org members registry — rescrape, add/delete; deal member picker with search + new person saves to global org

This commit is contained in:
James 2026-03-19 23:38:19 -04:00
parent 063c12d324
commit 23a009e60d
5 changed files with 605 additions and 124 deletions

View File

@ -1373,6 +1373,11 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
result["linkedin"] = orgData.LinkedIn result["linkedin"] = orgData.LinkedIn
result["contact_name"] = orgData.ContactName result["contact_name"] = orgData.ContactName
result["contact_email"] = orgData.ContactEmail result["contact_email"] = orgData.ContactEmail
if orgData.Members == nil {
result["members"] = []lib.DealOrgMember{}
} else {
result["members"] = orgData.Members
}
} }
} }
} }
@ -1551,6 +1556,158 @@ func (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) {
// UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization // UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization
// OrgDeals returns a list of deals (projects) this org is currently part of. // OrgDeals returns a list of deals (projects) this org is currently part of.
// UpdateOrgMembers handles PUT /api/orgs/{orgID}/members — replaces full members list.
func (h *Handlers) UpdateOrgMembers(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
orgID := chi.URLParam(r, "orgID")
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
isIBAdmin := h.isIBAdminAnywhere(actorID)
if !isSuperAdmin && !isIBAdmin {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
var body struct {
Members []lib.DealOrgMember `json:"members"`
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
if err != nil || org == nil || org.Type != lib.TypeOrganization {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
var orgData lib.OrgData
json.Unmarshal([]byte(org.DataText), &orgData)
orgData.Members = body.Members
dataJSON, _ := json.Marshal(orgData)
org.DataText = string(dataJSON)
org.Version = body.Version
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {
if err == lib.ErrVersionConflict {
ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update members")
return
}
JSONResponse(w, http.StatusOK, map[string]any{"ok": true, "version": org.Version})
}
// RescrapeOrg scrapes the org website for people and diffs against existing members.
func (h *Handlers) RescrapeOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
orgID := chi.URLParam(r, "orgID")
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
isIBAdmin := h.isIBAdminAnywhere(actorID)
if !isSuperAdmin && !isIBAdmin {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
if err != nil || org == nil || org.Type != lib.TypeOrganization {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
var orgData lib.OrgData
json.Unmarshal([]byte(org.DataText), &orgData)
if orgData.Website == "" && len(orgData.Domains) == 0 {
ErrorResponse(w, http.StatusBadRequest, "no_website", "No website or domain configured for this org")
return
}
website := orgData.Website
if website == "" {
website = "https://" + orgData.Domains[0]
}
// Extract domain from website
domain := strings.TrimPrefix(strings.TrimPrefix(website, "https://"), "http://")
domain = strings.TrimPrefix(domain, "www.")
domain = strings.Split(domain, "/")[0]
scraped, err := lib.ScrapeOrg(h.Cfg.OpenRouterKey, domain)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "scrape_failed", "Scrape failed: "+err.Error())
return
}
const maxPeople = 40
truncated := false
people := scraped.People
if len(people) > maxPeople {
people = people[:maxPeople]
truncated = true
}
// Diff: existing members keyed by email (lowercased) or name
type MemberStatus struct {
Person lib.DealOrgMember `json:"person"`
Status string `json:"status"` // "existing" | "new" | "not_found"
}
existingByEmail := map[string]lib.DealOrgMember{}
existingByName := map[string]lib.DealOrgMember{}
for _, m := range orgData.Members {
if m.Email != "" {
existingByEmail[strings.ToLower(m.Email)] = m
}
if m.Name != "" {
existingByName[strings.ToLower(m.Name)] = m
}
}
scrapedEmails := map[string]bool{}
scrapedNames := map[string]bool{}
var newPeople []lib.DealOrgMember
for _, p := range people {
email := strings.ToLower(p.Email)
name := strings.ToLower(p.Name)
if email != "" { scrapedEmails[email] = true }
if name != "" { scrapedNames[name] = true }
_, foundByEmail := existingByEmail[email]
_, foundByName := existingByName[name]
if !foundByEmail && !foundByName {
newPeople = append(newPeople, lib.DealOrgMember{
Name: p.Name, Email: p.Email, Title: p.Title,
Phone: p.Phone, Photo: p.Photo, LinkedIn: p.LinkedIn,
})
}
}
var notFound []lib.DealOrgMember
for _, m := range orgData.Members {
emailKey := strings.ToLower(m.Email)
nameKey := strings.ToLower(m.Name)
foundByEmail := m.Email != "" && scrapedEmails[emailKey]
foundByName := m.Name != "" && scrapedNames[nameKey]
if !foundByEmail && !foundByName {
notFound = append(notFound, m)
}
}
if newPeople == nil { newPeople = []lib.DealOrgMember{} }
if notFound == nil { notFound = []lib.DealOrgMember{} }
JSONResponse(w, http.StatusOK, map[string]any{
"new_people": newPeople,
"not_found": notFound,
"truncated": truncated,
"total_found": len(scraped.People),
})
}
func (h *Handlers) OrgDeals(w http.ResponseWriter, r *http.Request) { func (h *Handlers) OrgDeals(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context()) actorID := UserIDFromContext(r.Context())
orgID := chi.URLParam(r, "orgID") orgID := chi.URLParam(r, "orgID")

View File

@ -106,6 +106,8 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/orgs", h.ListOrgs) r.Get("/orgs", h.ListOrgs)
r.Post("/orgs", h.CreateOrg) r.Post("/orgs", h.CreateOrg)
r.Get("/orgs/{orgID}/deals", h.OrgDeals) r.Get("/orgs/{orgID}/deals", h.OrgDeals)
r.Put("/orgs/{orgID}/members", h.UpdateOrgMembers)
r.Post("/orgs/{orgID}/rescrape", h.RescrapeOrg)
r.Put("/orgs/{orgID}", h.UpdateOrg) r.Put("/orgs/{orgID}", h.UpdateOrg)
r.Delete("/orgs/{orgID}", h.DeleteOrg) r.Delete("/orgs/{orgID}", h.DeleteOrg)
r.Put("/admin/test-role", h.SetTestRole) r.Put("/admin/test-role", h.SetTestRole)

View File

@ -64,23 +64,24 @@ const (
// OrgData is the JSON structure packed into an organization entry's Data field. // OrgData is the JSON structure packed into an organization entry's Data field.
type OrgData struct { type OrgData struct {
Name string `json:"name"` Name string `json:"name"`
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"] Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
Role string `json:"role"` // seller | buyer | ib | advisor Role string `json:"role"` // seller | buyer | ib | advisor
Logo string `json:"logo,omitempty"` Logo string `json:"logo,omitempty"`
Website string `json:"website,omitempty"` Website string `json:"website,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Industry string `json:"industry,omitempty"` Industry string `json:"industry,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
Fax string `json:"fax,omitempty"` Fax string `json:"fax,omitempty"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
City string `json:"city,omitempty"` City string `json:"city,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
Founded string `json:"founded,omitempty"` Founded string `json:"founded,omitempty"`
LinkedIn string `json:"linkedin,omitempty"` LinkedIn string `json:"linkedin,omitempty"`
ContactName string `json:"contact_name,omitempty"` ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty"` ContactEmail string `json:"contact_email,omitempty"`
Members []DealOrgMember `json:"members,omitempty"` // global contact registry for this org
} }
// DealOrgData is the JSON structure packed into a deal_org entry's Data field. // DealOrgData is the JSON structure packed into a deal_org entry's Data field.

View File

@ -3,7 +3,7 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h1 class="text-2xl font-bold text-white mb-1">Organizations</h1> <h1 class="text-2xl font-bold text-white mb-1">Organizations</h1>
<p class="text-[#b0bec5] text-sm">Company directory &mdash; parties eligible to participate in deals.</p> <p class="text-[#b0bec5] text-sm">Company directory parties eligible to participate in deals.</p>
</div> </div>
<button id="newOrgBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Organization</button> <button id="newOrgBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Organization</button>
</div> </div>
@ -12,7 +12,7 @@
</div> </div>
<div id="emptyState" class="hidden text-center py-20"> <div id="emptyState" class="hidden text-center py-20">
<h2 class="text-xl font-semibold text-white mb-2">No organizations yet</h2> <h2 class="text-xl font-semibold text-white mb-2">No organizations yet</h2>
<p class="text-[#b0bec5] text-sm">Add buyer, seller, IB, and advisor organizations.</p> <p class="text-[#b0bec5] text-sm">Add organizations to get started.</p>
</div> </div>
</div> </div>
@ -26,8 +26,7 @@
<input id="oName" type="text" placeholder="Blackstone Group" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]"></div> <input id="oName" type="text" placeholder="Blackstone Group" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]"></div>
<div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Email Domains <span class="text-red-400">*</span></label> <div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Email Domains <span class="text-red-400">*</span></label>
<input id="oDomains" type="text" placeholder="blackstone.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]"> <input id="oDomains" type="text" placeholder="blackstone.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]">
<p class="text-[#8899a6] text-xs mt-1">Comma-separated. Used to validate invite emails.</p></div> <p class="text-[#8899a6] text-xs mt-1">Comma-separated.</p></div>
<div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Website</label> <div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Website</label>
<input id="oWebsite" type="text" placeholder="blackstone.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]"></div> <input id="oWebsite" type="text" placeholder="blackstone.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c]"></div>
</div> </div>
@ -38,77 +37,90 @@
</div> </div>
</div> </div>
<!-- Edit Org Modal --> <!-- Edit Org Modal (tabbed: Details | Members) -->
<div id="editOrgModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"> <div id="editOrgModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"> <div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-2xl flex flex-col" style="max-height:90vh">
<div class="flex items-center justify-between px-6 pt-5 pb-4 border-b border-white/[0.08]"> <!-- Header -->
<div class="flex items-center justify-between px-6 pt-5 pb-0 shrink-0">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div id="eLogoWrap" class="hidden w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white flex items-center justify-center shrink-0"> <div id="eLogoWrap" class="hidden w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center">
<img id="eLogoImg" src="" class="max-w-full max-h-full object-contain"> <img id="eLogoImg" src="" class="max-w-full max-h-full object-contain">
</div> </div>
<h2 class="text-lg font-semibold text-white">Edit Organization</h2> <h2 id="editOrgTitle" class="text-lg font-semibold text-white">Edit Organization</h2>
</div> </div>
<button onclick="closeEditModal()" class="text-[#b0bec5] hover:text-white text-2xl leading-none">&times;</button> <button onclick="closeEditModal()" class="text-[#b0bec5] hover:text-white text-2xl leading-none">&times;</button>
</div> </div>
<div class="p-6"> <!-- Tabs -->
<div class="flex gap-0 px-6 mt-4 border-b shrink-0" style="border-color:var(--ds-bd)">
<button id="tabDetails" onclick="switchEditTab('details')" class="px-4 py-2 text-sm font-medium border-b-2 border-[#c9a84c] text-white transition -mb-px">Details</button>
<button id="tabMembers" onclick="switchEditTab('members')" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-[#94a3b8] hover:text-white transition -mb-px">Members <span id="memberCountBadge" class="ml-1 px-1.5 py-0.5 rounded-full text-xs bg-white/[0.08]">0</span></button>
</div>
<!-- Details Tab -->
<div id="editTabDetails" class="overflow-y-auto p-6">
<input type="hidden" id="eOrgId"> <input type="hidden" id="eOrgId">
<input type="hidden" id="eVersion"> <input type="hidden" id="eVersion">
<div id="editModalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div> <div id="editModalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="col-span-2"> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Name <span class="text-red-400">*</span></label>
<label class="block text-xs text-[#b0bec5] mb-1">Name <span class="text-red-400">*</span></label> <input id="eName" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<input id="eName" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Description</label>
</div> <textarea id="eDesc" rows="2" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] resize-none"></textarea></div>
<div class="col-span-2"> <div><label class="block text-xs text-[#b0bec5] mb-1">Industry</label>
<label class="block text-xs text-[#b0bec5] mb-1">Description</label> <input id="eIndustry" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<textarea id="eDesc" rows="3" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] resize-none"></textarea> <div><label class="block text-xs text-[#b0bec5] mb-1">Website</label>
</div> <input id="eWebsite" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<div><label class="block text-xs text-[#b0bec5] mb-1">Phone</label>
<div> <input id="ePhone" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<label class="block text-xs text-[#b0bec5] mb-1">Industry</label> <div><label class="block text-xs text-[#b0bec5] mb-1">LinkedIn</label>
<input id="eIndustry" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"> <input id="eLinkedIn" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
</div> <div><label class="block text-xs text-[#b0bec5] mb-1">Founded</label>
<div> <input id="eFounded" type="text" placeholder="e.g. 1985" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<label class="block text-xs text-[#b0bec5] mb-1">Website</label> <div><label class="block text-xs text-[#b0bec5] mb-1">State</label>
<input id="eWebsite" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"> <input id="eState" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
</div> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Address</label>
<div> <input id="eAddress" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"></div>
<label class="block text-xs text-[#b0bec5] mb-1">Phone</label> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Email Domains</label>
<input id="ePhone" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"> <input id="eDomains" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div> <p class="text-xs text-[#64748b] mt-1">Comma-separated. Used to validate invite emails.</p></div>
<div> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
<label class="block text-xs text-[#b0bec5] mb-1">LinkedIn</label> <input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]" oninput="previewEditLogo()"></div>
<input id="eLinkedIn" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Founded</label>
<input id="eFounded" type="text" placeholder="e.g. 1985" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Address</label>
<input id="eAddress" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">City</label>
<input id="eCity" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">State</label>
<input id="eState" type="text" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Email Domains</label>
<input id="eDomains" type="text" placeholder="blackstone.com, pe.blackstone.com" class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
<p class="text-[#8899a6] text-xs mt-1">Comma-separated. Used to validate invite emails.</p>
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
<input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]"
oninput="previewEditLogo()">
</div>
</div> </div>
</div> </div>
<div class="flex gap-3 px-6 pb-6"> <!-- Members Tab -->
<div id="editTabMembers" class="hidden overflow-y-auto p-6 flex-1">
<!-- Rescrape panel -->
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-white">Team members</span>
<button id="rescrapeBtn" onclick="startRescrape()" class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition" style="background:var(--ds-hv);color:var(--ds-tx2)">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Rescrape website
</button>
</div>
<!-- Rescrape results -->
<div id="rescrapeResults" class="hidden mb-4 rounded-xl overflow-hidden" style="border:1px solid var(--ds-bd)">
<div class="px-4 py-3 flex items-center justify-between" style="background:var(--ds-hv)">
<span id="rescrapeTitle" class="text-sm font-medium text-white"></span>
<button onclick="document.getElementById('rescrapeResults').classList.add('hidden')" class="text-[#94a3b8] hover:text-white text-lg leading-none">&times;</button>
</div>
<div id="rescrapeContent" class="p-4 space-y-3"></div>
</div>
<!-- Member list -->
<div id="memberList" class="space-y-2 mb-4"></div>
<div id="noMembers" class="hidden text-sm text-center py-6" style="color:var(--ds-tx3)">No members yet</div>
<!-- Add manually -->
<div class="pt-3 border-t" style="border-color:var(--ds-bd)">
<p class="text-xs mb-2" style="color:var(--ds-tx3)">Add manually</p>
<div class="grid gap-2" style="grid-template-columns:1fr 1fr 1fr auto">
<input type="text" id="newMemberName" placeholder="Name" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<input type="email" id="newMemberEmail" placeholder="Email" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<input type="text" id="newMemberTitle" placeholder="Title" onkeydown="if(event.key==='Enter'){event.preventDefault();addGlobalMember();}" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<button onclick="addGlobalMember()" class="px-3 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-bold rounded-lg text-sm transition">+</button>
</div>
<div id="addMemberError" class="hidden mt-2 text-xs text-red-400"></div>
</div>
</div>
<!-- Footer -->
<div class="flex gap-3 px-6 py-4 border-t shrink-0" style="border-color:var(--ds-bd)">
<button onclick="confirmDeleteOrg()" class="px-4 py-2.5 rounded-lg text-sm font-medium transition" style="background:rgba(239,68,68,0.1);color:#ef4444;border:1px solid rgba(239,68,68,0.2)">Delete org</button> <button onclick="confirmDeleteOrg()" class="px-4 py-2.5 rounded-lg text-sm font-medium transition" style="background:rgba(239,68,68,0.1);color:#ef4444;border:1px solid rgba(239,68,68,0.2)">Delete org</button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button onclick="closeEditModal()" class="px-4 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button> <button onclick="closeEditModal()" class="px-4 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
@ -123,9 +135,9 @@
<h2 class="text-lg font-semibold text-white mb-2">Delete Organization</h2> <h2 class="text-lg font-semibold text-white mb-2">Delete Organization</h2>
<p class="text-[#b0bec5] text-sm mb-4">You are about to permanently delete <strong id="deleteOrgName" class="text-white"></strong> from the company registry.</p> <p class="text-[#b0bec5] text-sm mb-4">You are about to permanently delete <strong id="deleteOrgName" class="text-white"></strong> from the company registry.</p>
<div id="deleteDealsWrap" class="hidden mb-4 p-4 rounded-lg" style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2)"> <div id="deleteDealsWrap" class="hidden mb-4 p-4 rounded-lg" style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2)">
<p class="text-red-400 text-sm font-medium mb-2">⚠️ This org is currently active in the following deals:</p> <p class="text-red-400 text-sm font-medium mb-2">⚠️ Active in the following deals:</p>
<ul id="dealsList" class="text-sm text-[#b0bec5] space-y-1 list-disc list-inside"></ul> <ul id="dealsList" class="text-sm text-[#b0bec5] space-y-1 list-disc list-inside"></ul>
<p class="text-red-400 text-xs mt-2">Deleting will also remove them from all these deals.</p> <p class="text-red-400 text-xs mt-2">Deleting removes them from all these deals.</p>
</div> </div>
<div id="deleteOrgError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div> <div id="deleteOrgError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="flex gap-3"> <div class="flex gap-3">
@ -140,13 +152,6 @@
<script> <script>
if (user.is_super_admin || user.role === 'ib_admin') document.getElementById('newOrgBtn').classList.remove('hidden'); if (user.is_super_admin || user.role === 'ib_admin') document.getElementById('newOrgBtn').classList.remove('hidden');
const roleColors = {
seller: 'bg-blue-500/20 text-blue-300',
buyer: 'bg-green-500/20 text-green-300',
ib: 'bg-[#c9a84c]/20 text-[#c9a84c]',
advisor: 'bg-purple-500/20 text-purple-300'
};
async function loadOrgs() { async function loadOrgs() {
try { try {
const res = await fetchAPI('/api/orgs'); const res = await fetchAPI('/api/orgs');
@ -162,18 +167,20 @@
grid.innerHTML = orgs.map(o => { grid.innerHTML = orgs.map(o => {
const name = o.name || 'Untitled'; const name = o.name || 'Untitled';
const domains = Array.isArray(o.domains) ? o.domains : (o.domains ? [o.domains] : []); const domains = Array.isArray(o.domains) ? o.domains : [];
const memberCount = Array.isArray(o.members) ? o.members.length : 0;
const logo = o.logo || ''; const logo = o.logo || '';
return `<div onclick="openEditModal('${o.entry_id}')" return '<div onclick="openEditModal(\'' + escHtml(o.entry_id) + '\')" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition">'
class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition"> + '<div class="flex items-start gap-3 mb-3">'
<div class="flex items-start gap-3 mb-3"> + (logo ? '<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="' + escHtml(logo) + '" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display=\'none\'"></div>' : '')
${logo ? `<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="${escHtml(logo)}" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display='none'"></div>` : ''} + '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>'
<h3 class="text-white font-semibold leading-tight truncate">${escHtml(name)}</h3> + '</div>'
</div> + (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '')
${o.description ? `<p class="text-xs text-[#94a3b8] mb-2 line-clamp-2">${escHtml(o.description)}</p>` : ''} + '<div class="flex items-center justify-between mt-1">'
<div class="flex gap-1.5 flex-wrap">${domains.map(dm => `<span class="text-xs font-mono text-[#b0bec5] bg-white/[0.05] px-2 py-0.5 rounded">@${escHtml(dm)}</span>`).join('')}</div> + '<div class="flex gap-1.5 flex-wrap">' + domains.map(d => '<span class="text-xs font-mono text-[#b0bec5] bg-white/[0.05] px-2 py-0.5 rounded">@' + escHtml(d) + '</span>').join('') + '</div>'
${o.website ? `<div class="mt-2 text-xs text-[#64748b]">${escHtml(o.website)}</div>` : ''} + (memberCount > 0 ? '<span class="text-xs text-[#94a3b8]">' + memberCount + ' member' + (memberCount !== 1 ? 's' : '') + '</span>' : '')
</div>`; + '</div>'
+ '</div>';
}).join(''); }).join('');
} catch(e) { } catch(e) {
document.getElementById('orgGrid').innerHTML = '<div class="text-red-400 text-sm col-span-2">Failed to load.</div>'; document.getElementById('orgGrid').innerHTML = '<div class="text-red-400 text-sm col-span-2">Failed to load.</div>';
@ -207,34 +214,41 @@
// ---- Edit Org ---- // ---- Edit Org ----
let editingOrgId = null; let editingOrgId = null;
let editingMembers = [];
function switchEditTab(tab) {
document.getElementById('editTabDetails').classList.toggle('hidden', tab !== 'details');
document.getElementById('editTabMembers').classList.toggle('hidden', tab !== 'members');
document.getElementById('tabDetails').className = 'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition ' + (tab === 'details' ? 'border-[#c9a84c] text-white' : 'border-transparent text-[#94a3b8] hover:text-white');
document.getElementById('tabMembers').className = 'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition ' + (tab === 'members' ? 'border-[#c9a84c] text-white' : 'border-transparent text-[#94a3b8] hover:text-white');
}
function openEditModal(entryId) { function openEditModal(entryId) {
const o = (window._orgsMap || {})[entryId]; const o = (window._orgsMap || {})[entryId];
if (!o) return; if (!o) return;
editingOrgId = o.entry_id; editingOrgId = o.entry_id;
editingMembers = (o.members || []).map(m => Object.assign({}, m));
document.getElementById('eOrgId').value = o.entry_id; document.getElementById('eOrgId').value = o.entry_id;
document.getElementById('eVersion').value = o.version || 1; document.getElementById('eVersion').value = o.version || 1;
document.getElementById('eName').value = o.name || ''; document.getElementById('eName').value = o.name || '';
document.getElementById('eDesc').value = o.description || ''; document.getElementById('eDesc').value = o.description || '';
document.getElementById('eIndustry').value = o.industry || ''; document.getElementById('eIndustry').value = o.industry || '';
document.getElementById('eWebsite').value = o.website || ''; document.getElementById('eWebsite').value = o.website || '';
document.getElementById('ePhone').value = o.phone || ''; document.getElementById('ePhone').value = o.phone || '';
document.getElementById('eLinkedIn').value = o.linkedin || ''; document.getElementById('eLinkedIn').value = o.linkedin || '';
document.getElementById('eFounded').value = o.founded || ''; document.getElementById('eFounded').value = o.founded || '';
document.getElementById('eAddress').value = o.address || ''; document.getElementById('eAddress').value = o.address || '';
document.getElementById('eCity').value = o.city || '';
document.getElementById('eState').value = o.state || ''; document.getElementById('eState').value = o.state || '';
document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : (o.domains || ''); document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : '';
document.getElementById('eLogo').value = o.logo || ''; document.getElementById('eLogo').value = o.logo || '';
document.getElementById('editModalError').classList.add('hidden'); document.getElementById('editModalError').classList.add('hidden');
document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization';
const logo = o.logo || ''; const logo = o.logo || '';
if (logo) { if (logo) { document.getElementById('eLogoImg').src = logo; document.getElementById('eLogoWrap').classList.remove('hidden'); }
document.getElementById('eLogoImg').src = logo; else { document.getElementById('eLogoWrap').classList.add('hidden'); }
document.getElementById('eLogoWrap').classList.remove('hidden'); switchEditTab('details');
} else { document.getElementById('rescrapeResults').classList.add('hidden');
document.getElementById('eLogoWrap').classList.add('hidden'); renderMemberList();
}
document.getElementById('editOrgModal').classList.remove('hidden'); document.getElementById('editOrgModal').classList.remove('hidden');
document.getElementById('eName').focus(); document.getElementById('eName').focus();
} }
@ -242,6 +256,7 @@
function closeEditModal() { function closeEditModal() {
document.getElementById('editOrgModal').classList.add('hidden'); document.getElementById('editOrgModal').classList.add('hidden');
editingOrgId = null; editingOrgId = null;
editingMembers = [];
} }
function previewEditLogo() { function previewEditLogo() {
@ -261,12 +276,11 @@
const domains = domainsRaw ? domainsRaw.split(',').map(d => d.trim().replace(/^@/, '')).filter(Boolean) : undefined; const domains = domainsRaw ? domainsRaw.split(',').map(d => d.trim().replace(/^@/, '')).filter(Boolean) : undefined;
btn.disabled = true; btn.textContent = 'Saving...'; errEl.classList.add('hidden'); btn.disabled = true; btn.textContent = 'Saving...'; errEl.classList.add('hidden');
try { try {
const version = parseInt(document.getElementById('eVersion').value) || 1;
const res = await fetchAPI('/api/orgs/' + editingOrgId, { const res = await fetchAPI('/api/orgs/' + editingOrgId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
name, name, domains,
domains,
description: document.getElementById('eDesc').value.trim(), description: document.getElementById('eDesc').value.trim(),
industry: document.getElementById('eIndustry').value.trim(), industry: document.getElementById('eIndustry').value.trim(),
website: document.getElementById('eWebsite').value.trim(), website: document.getElementById('eWebsite').value.trim(),
@ -274,19 +288,150 @@
linkedin: document.getElementById('eLinkedIn').value.trim(), linkedin: document.getElementById('eLinkedIn').value.trim(),
founded: document.getElementById('eFounded').value.trim(), founded: document.getElementById('eFounded').value.trim(),
address: document.getElementById('eAddress').value.trim(), address: document.getElementById('eAddress').value.trim(),
city: document.getElementById('eCity').value.trim(),
state: document.getElementById('eState').value.trim(), state: document.getElementById('eState').value.trim(),
logo: document.getElementById('eLogo').value.trim(), logo: document.getElementById('eLogo').value.trim(),
version: parseInt(document.getElementById('eVersion').value) || 1, version,
}) })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save'); if (!res.ok) throw new Error(data.error || 'Failed to save');
// Also save members separately
await saveMembersToServer(data.version || version + 1);
closeEditModal(); closeEditModal();
loadOrgs(); loadOrgs();
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Save Changes'; } } catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Save Changes'; }
} }
async function saveMembersToServer(version) {
await fetchAPI('/api/orgs/' + editingOrgId + '/members', {
method: 'PUT',
body: JSON.stringify({ members: editingMembers, version })
});
}
// ---- Members Tab ----
function renderMemberList() {
const list = document.getElementById('memberList');
const none = document.getElementById('noMembers');
document.getElementById('memberCountBadge').textContent = editingMembers.length;
if (editingMembers.length === 0) { list.innerHTML = ''; none.classList.remove('hidden'); return; }
none.classList.add('hidden');
list.innerHTML = editingMembers.map((m, i) => {
const initial = (m.name || m.email || '?')[0].toUpperCase();
return '<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg" style="background:var(--ds-hv)">'
+ '<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-sm font-semibold" style="background:var(--ds-ac);color:var(--ds-act)">' + escHtml(initial) + '</div>'
+ '<div class="flex-1 min-w-0">'
+ '<div class="text-sm font-medium truncate" style="color:var(--ds-tx)">' + escHtml(m.name || m.email || '—') + '</div>'
+ (m.title ? '<div class="text-xs truncate" style="color:var(--ds-tx3)">' + escHtml(m.title) + '</div>' : '')
+ (m.email && m.name ? '<div class="text-xs truncate" style="color:var(--ds-tx3)">' + escHtml(m.email) + '</div>' : '')
+ '</div>'
+ '<button onclick="removeGlobalMember(' + i + ')" class="shrink-0 px-2 py-1 rounded text-xs transition hover:opacity-80" style="background:rgba(239,68,68,0.1);color:#ef4444">Remove</button>'
+ '</div>';
}).join('');
}
function addGlobalMember() {
const name = document.getElementById('newMemberName').value.trim();
const email = document.getElementById('newMemberEmail').value.trim();
const title = document.getElementById('newMemberTitle').value.trim();
const errEl = document.getElementById('addMemberError');
if (!name && !email) {
errEl.textContent = 'Enter a name or email.';
errEl.classList.remove('hidden');
setTimeout(() => errEl.classList.add('hidden'), 2500);
document.getElementById('newMemberName').focus();
return;
}
errEl.classList.add('hidden');
editingMembers.push({ name, email, title, phone: '', photo: '', bio: '', linkedin: '' });
document.getElementById('newMemberName').value = '';
document.getElementById('newMemberEmail').value = '';
document.getElementById('newMemberTitle').value = '';
renderMemberList();
document.getElementById('newMemberName').focus();
}
function removeGlobalMember(idx) {
editingMembers.splice(idx, 1);
renderMemberList();
}
// ---- Rescrape ----
async function startRescrape() {
const btn = document.getElementById('rescrapeBtn');
const resultsEl = document.getElementById('rescrapeResults');
btn.disabled = true;
btn.innerHTML = '<svg class="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> Scanning...';
resultsEl.classList.add('hidden');
try {
const res = await fetchAPI('/api/orgs/' + editingOrgId + '/rescrape', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Scrape failed');
renderRescrapeResults(data);
} catch(e) {
document.getElementById('rescrapeTitle').textContent = 'Scrape failed: ' + e.message;
document.getElementById('rescrapeContent').innerHTML = '';
resultsEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> Rescrape website';
}
}
function renderRescrapeResults(data) {
const newPeople = data.new_people || [];
const notFound = data.not_found || [];
const truncated = data.truncated;
const total = data.total_found || 0;
let title = '';
if (newPeople.length > 0) title = newPeople.length + ' new person' + (newPeople.length !== 1 ? 's' : '') + ' found on website';
else title = 'No new people found';
if (truncated) title += ' (showing first 40 of ' + total + ' — rescrape again for more)';
document.getElementById('rescrapeTitle').textContent = title;
let html = '';
if (truncated) {
html += '<p class="text-xs text-[#c9a84c] mb-3">⚠️ Only the first 40 results were pulled. You can rescrape again to get additional people.</p>';
}
if (newPeople.length > 0) {
html += '<div class="mb-3"><p class="text-xs font-medium mb-2" style="color:var(--ds-tx2)">New people — select to add:</p><div class="space-y-1.5">';
newPeople.forEach((p, i) => {
html += '<label class="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:opacity-80" style="background:var(--ds-sf)">'
+ '<input type="checkbox" class="rescrape-cb accent-[#c9a84c]" data-idx="' + i + '">'
+ '<div class="min-w-0 flex-1"><div class="text-sm text-white truncate">' + escHtml(p.name || p.email) + '</div>'
+ (p.title ? '<div class="text-xs truncate" style="color:var(--ds-tx3)">' + escHtml(p.title) + '</div>' : '') + '</div>'
+ (p.email ? '<div class="text-xs shrink-0" style="color:var(--ds-tx3)">' + escHtml(p.email) + '</div>' : '')
+ '</label>';
});
html += '</div>'
+ '<button onclick="addRescrapeSelected(' + JSON.stringify(newPeople).replace(/</g,'\\u003c') + ')" class="mt-2 px-3 py-1.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-xs transition">Add selected</button>'
+ '</div>';
}
if (notFound.length > 0) {
html += '<div class="pt-3 border-t" style="border-color:var(--ds-bd)"><p class="text-xs font-medium mb-2 text-amber-400">⚠️ These people are in your database but not found on the website — possibly inactive:</p><div class="space-y-1">';
notFound.forEach(p => {
html += '<div class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg" style="background:rgba(245,158,11,0.05);border:1px solid rgba(245,158,11,0.15)">'
+ '<span class="text-[#b0bec5] flex-1 truncate">' + escHtml(p.name || p.email) + (p.title ? ' · ' + escHtml(p.title) : '') + '</span>'
+ '<span class="text-amber-400 shrink-0">Not on site</span></div>';
});
html += '</div></div>';
}
if (!html) html = '<p class="text-sm text-[#94a3b8]">Everyone in your database was found on the website. No suggestions.</p>';
document.getElementById('rescrapeContent').innerHTML = html;
document.getElementById('rescrapeResults').classList.remove('hidden');
// Store new people for the add button
window._rescrapeNewPeople = newPeople;
}
function addRescrapeSelected(people) {
const checked = document.querySelectorAll('.rescrape-cb:checked');
checked.forEach(cb => {
const p = people[parseInt(cb.dataset.idx)];
if (p) editingMembers.push({ name: p.name || '', email: p.email || '', title: p.title || '', phone: p.phone || '', photo: p.photo || '', bio: p.bio || '', linkedin: p.linkedin || '' });
});
renderMemberList();
document.getElementById('rescrapeResults').classList.add('hidden');
}
// ---- Delete Org ---- // ---- Delete Org ----
async function confirmDeleteOrg() { async function confirmDeleteOrg() {
if (!editingOrgId) return; if (!editingOrgId) return;
@ -296,21 +441,17 @@
document.getElementById('deleteDealsWrap').classList.add('hidden'); document.getElementById('deleteDealsWrap').classList.add('hidden');
document.getElementById('dealsList').innerHTML = ''; document.getElementById('dealsList').innerHTML = '';
document.getElementById('deleteOrgModal').classList.remove('hidden'); document.getElementById('deleteOrgModal').classList.remove('hidden');
// Fetch deals this org is in
try { try {
const res = await fetchAPI('/api/orgs/' + editingOrgId + '/deals'); const res = await fetchAPI('/api/orgs/' + editingOrgId + '/deals');
const deals = await res.json(); const deals = await res.json();
if (deals && deals.length > 0) { if (deals && deals.length > 0) {
document.getElementById('dealsList').innerHTML = deals.map(d => `<li>${escHtml(d.name)}</li>`).join(''); document.getElementById('dealsList').innerHTML = deals.map(d => '<li>' + escHtml(d.name) + '</li>').join('');
document.getElementById('deleteDealsWrap').classList.remove('hidden'); document.getElementById('deleteDealsWrap').classList.remove('hidden');
} }
} catch(e) { /* non-fatal */ } } catch(e) {}
} }
function closeDeleteModal() { function closeDeleteModal() { document.getElementById('deleteOrgModal').classList.add('hidden'); }
document.getElementById('deleteOrgModal').classList.add('hidden');
}
async function executeDeleteOrg() { async function executeDeleteOrg() {
const btn = document.getElementById('confirmDeleteBtn'); const btn = document.getElementById('confirmDeleteBtn');
@ -319,9 +460,7 @@
try { try {
const res = await fetchAPI('/api/orgs/' + editingOrgId, { method: 'DELETE' }); const res = await fetchAPI('/api/orgs/' + editingOrgId, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); } if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
closeDeleteModal(); closeDeleteModal(); closeEditModal(); loadOrgs();
closeEditModal();
loadOrgs();
} catch(e) { } catch(e) {
errEl.textContent = e.message; errEl.classList.remove('hidden'); errEl.textContent = e.message; errEl.classList.remove('hidden');
btn.disabled = false; btn.textContent = 'Delete permanently'; btn.disabled = false; btn.textContent = 'Delete permanently';

View File

@ -153,6 +153,42 @@
</div> </div>
</div> </div>
<!-- Deal Member Picker Modal -->
<div id="dealMemberPickerModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-lg flex flex-col" style="max-height:85vh">
<div class="flex items-center justify-between px-5 pt-5 pb-3 shrink-0">
<div>
<h2 class="text-base font-semibold text-white">Add people to deal</h2>
<p id="dmpOrgName" class="text-xs mt-0.5" style="color:var(--ds-tx3)"></p>
</div>
<button onclick="closeDealMemberPicker()" class="text-[#94a3b8] hover:text-white text-2xl leading-none">&times;</button>
</div>
<!-- Search -->
<div class="px-5 pb-3 shrink-0">
<input type="text" id="dmpSearch" placeholder="Search by name or email..." oninput="filterDmpList()"
class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
</div>
<!-- Existing members from global org -->
<div id="dmpList" class="overflow-y-auto flex-1 px-5 space-y-1.5"></div>
<!-- Add new person inline -->
<div class="px-5 py-4 border-t shrink-0" style="border-color:var(--ds-bd)">
<p class="text-xs mb-2 font-medium" style="color:var(--ds-tx3)">Not listed? Add a new person:</p>
<div class="grid gap-2" style="grid-template-columns:1fr 1fr 1fr auto">
<input type="text" id="dmpNewName" placeholder="Name" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<input type="email" id="dmpNewEmail" placeholder="Email" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<input type="text" id="dmpNewTitle" placeholder="Title" onkeydown="if(event.key==='Enter'){event.preventDefault();addNewDealMember();}" class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#64748b]">
<button onclick="addNewDealMember()" class="px-3 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-bold rounded-lg text-sm transition">+</button>
</div>
<div id="dmpAddError" class="hidden mt-1.5 text-xs text-red-400"></div>
</div>
<!-- Footer -->
<div class="flex gap-3 px-5 pb-5 shrink-0">
<button onclick="closeDealMemberPicker()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="dmpAddBtn" onclick="confirmDealMemberAdd()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Add selected</button>
</div>
</div>
</div>
<!-- Template Picker Modal --> <!-- Template Picker Modal -->
<div id="templateModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"> <div id="templateModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] rounded-xl w-full max-w-lg border border-white/[0.08]"> <div class="bg-[#0d1f3c] rounded-xl w-full max-w-lg border border-white/[0.08]">
@ -1372,7 +1408,7 @@
<div class="border-t px-6 py-4" style="border-color:var(--ds-bd)"> <div class="border-t px-6 py-4" style="border-color:var(--ds-bd)">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr))"> <div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr))">
${members.map((m, mi) => renderMemberCard(o.deal_org_id, m, mi)).join('')} ${members.map((m, mi) => renderMemberCard(o.deal_org_id, m, mi)).join('')}
<div class="flex items-center gap-3 cursor-pointer hover:opacity-80 transition" onclick="openPersonPanel('${o.deal_org_id}', -1)"> <div class="flex items-center gap-3 cursor-pointer hover:opacity-80 transition" onclick="openDealMemberPicker('' + o.deal_org_id + '', '' + escHtml(o.org_id) + '', '' + escHtml(o.org_name||'') + '')">
<div class="w-11 h-11 rounded-full shrink-0 flex items-center justify-center text-xl" style="background:var(--ds-hv);color:var(--ds-tx2)">+</div> <div class="w-11 h-11 rounded-full shrink-0 flex items-center justify-center text-xl" style="background:var(--ds-hv);color:var(--ds-tx2)">+</div>
<span class="text-sm" style="color:var(--ds-tx3)">Add person</span> <span class="text-sm" style="color:var(--ds-tx3)">Add person</span>
</div> </div>
@ -1979,6 +2015,152 @@
}); });
// ---- Permissions Modal ---- // ---- Permissions Modal ----
// ---- Deal Member Picker ----
let _dmpDealOrgId = null;
let _dmpOrgId = null;
let _dmpAllMembers = []; // global org members
async function openDealMemberPicker(dealOrgId, orgId, orgName) {
_dmpDealOrgId = dealOrgId;
_dmpOrgId = orgId;
document.getElementById('dmpOrgName').textContent = orgName || '';
document.getElementById('dmpSearch').value = '';
document.getElementById('dmpAddError').classList.add('hidden');
['dmpNewName','dmpNewEmail','dmpNewTitle'].forEach(id => document.getElementById(id).value = '');
document.getElementById('dmpList').innerHTML = '<div class="text-sm py-4 text-center" style="color:var(--ds-tx3)">Loading...</div>';
document.getElementById('dealMemberPickerModal').classList.remove('hidden');
// Load global org members
try {
const res = await fetchAPI('/api/orgs/' + orgId);
const org = await res.json();
_dmpAllMembers = org.members || [];
// Mark which are already in the deal
const dealOrg = _dealOrgsMap[dealOrgId] || {};
const dealMembers = dealOrg.members || [];
const dealEmails = new Set(dealMembers.map(m => (m.email||'').toLowerCase()));
const dealNames = new Set(dealMembers.map(m => (m.name||'').toLowerCase()));
renderDmpList(_dmpAllMembers, dealEmails, dealNames);
} catch(e) {
_dmpAllMembers = [];
document.getElementById('dmpList').innerHTML = '<div class="text-sm py-4 text-center text-red-400">Failed to load members.</div>';
}
}
function renderDmpList(members, dealEmails, dealNames) {
const list = document.getElementById('dmpList');
if (!dealEmails) {
const dealOrg = _dealOrgsMap[_dmpDealOrgId] || {};
const dm = dealOrg.members || [];
dealEmails = new Set(dm.map(m => (m.email||'').toLowerCase()));
dealNames = new Set(dm.map(m => (m.name||'').toLowerCase()));
}
if (members.length === 0) {
list.innerHTML = '<div class="text-sm py-4 text-center" style="color:var(--ds-tx3)">No members in the global registry for this org yet. Add someone below.</div>';
return;
}
const query = (document.getElementById('dmpSearch').value || '').toLowerCase();
const filtered = query ? members.filter(m => (m.name||'').toLowerCase().includes(query) || (m.email||'').toLowerCase().includes(query)) : members;
if (filtered.length === 0) {
list.innerHTML = '<div class="text-sm py-4 text-center" style="color:var(--ds-tx3)">No matches</div>';
return;
}
list.innerHTML = filtered.map((m, i) => {
const inDeal = (m.email && dealEmails.has(m.email.toLowerCase())) || (m.name && dealNames.has(m.name.toLowerCase()));
const initial = (m.name || m.email || '?')[0].toUpperCase();
return '<label class="flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition ' + (inDeal ? 'opacity-50' : 'hover:opacity-80') + '" style="background:var(--ds-hv)">'
+ '<input type="checkbox" class="dmp-cb accent-[#c9a84c] shrink-0" data-idx="' + members.indexOf(m) + '" ' + (inDeal ? 'disabled checked' : '') + '>'
+ '<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-sm font-semibold" style="background:var(--ds-ac);color:var(--ds-act)">' + escHtml(initial) + '</div>'
+ '<div class="flex-1 min-w-0">'
+ '<div class="text-sm font-medium truncate" style="color:var(--ds-tx)">' + escHtml(m.name || m.email) + '</div>'
+ (m.title ? '<div class="text-xs truncate" style="color:var(--ds-tx3)">' + escHtml(m.title) + '</div>' : '')
+ '</div>'
+ (m.email ? '<div class="text-xs shrink-0" style="color:var(--ds-tx3)">' + escHtml(m.email) + '</div>' : '')
+ (inDeal ? '<span class="text-xs shrink-0 px-2 py-0.5 rounded-full" style="background:var(--ds-ac);color:var(--ds-act)">In deal</span>' : '')
+ '</label>';
}).join('');
}
function filterDmpList() {
renderDmpList(_dmpAllMembers, null, null);
}
function closeDealMemberPicker() {
document.getElementById('dealMemberPickerModal').classList.add('hidden');
_dmpDealOrgId = null; _dmpOrgId = null; _dmpAllMembers = [];
}
async function addNewDealMember() {
const name = document.getElementById('dmpNewName').value.trim();
const email = document.getElementById('dmpNewEmail').value.trim();
const title = document.getElementById('dmpNewTitle').value.trim();
const errEl = document.getElementById('dmpAddError');
if (!name && !email) {
errEl.textContent = 'Enter a name or email.';
errEl.classList.remove('hidden');
setTimeout(() => errEl.classList.add('hidden'), 2500);
return;
}
errEl.classList.add('hidden');
const newMember = { name, email, title, phone: '', photo: '', bio: '', linkedin: '' };
// 1. Save to global org registry
const orgRes = await fetchAPI('/api/orgs/' + _dmpOrgId);
const org = await orgRes.json();
const updatedGlobalMembers = [...(org.members || []), newMember];
await fetchAPI('/api/orgs/' + _dmpOrgId + '/members', {
method: 'PUT',
body: JSON.stringify({ members: updatedGlobalMembers, version: org.version || 1 })
});
// 2. Add to deal too
const dealOrg = _dealOrgsMap[_dmpDealOrgId] || {};
const updatedDealMembers = [...(dealOrg.members || []), newMember];
const raw = dealOrg._raw || {};
await fetchAPI('/api/projects/' + projectID + '/entries/' + _dmpDealOrgId, {
method: 'PUT',
body: JSON.stringify({
data: JSON.stringify({ ...raw, members: updatedDealMembers }),
version: dealOrg.version || 1
})
});
['dmpNewName','dmpNewEmail','dmpNewTitle'].forEach(id => document.getElementById(id).value = '');
_dmpAllMembers = updatedGlobalMembers;
closeDealMemberPicker();
loadOrgs();
}
async function confirmDealMemberAdd() {
const checked = [...document.querySelectorAll('.dmp-cb:checked:not([disabled])')];
if (checked.length === 0) { closeDealMemberPicker(); return; }
const toAdd = checked.map(cb => _dmpAllMembers[parseInt(cb.dataset.idx)]).filter(Boolean);
const dealOrg = _dealOrgsMap[_dmpDealOrgId] || {};
const existing = dealOrg.members || [];
const existEmails = new Set(existing.map(m => (m.email||'').toLowerCase()));
const existNames = new Set(existing.map(m => (m.name||'').toLowerCase()));
const fresh = toAdd.filter(m => {
if (m.email && existEmails.has(m.email.toLowerCase())) return false;
if (m.name && existNames.has(m.name.toLowerCase())) return false;
return true;
});
if (fresh.length === 0) { closeDealMemberPicker(); return; }
const updatedMembers = [...existing, ...fresh];
const raw = dealOrg._raw || {};
try {
const res = await fetchAPI('/api/projects/' + projectID + '/entries/' + _dmpDealOrgId, {
method: 'PUT',
body: JSON.stringify({
data: JSON.stringify({ ...raw, members: updatedMembers }),
version: dealOrg.version || 1
})
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Failed'); }
closeDealMemberPicker();
loadOrgs();
} catch(e) { alert('Failed to add members: ' + e.message); }
}
// ---- Edit Org Modal ---- // ---- Edit Org Modal ----
function openEditOrgModal(dealOrgId) { function openEditOrgModal(dealOrgId) {
const o = _dealOrgsMap[dealOrgId]; const o = _dealOrgsMap[dealOrgId];