feat: global org members registry — rescrape, add/delete; deal member picker with search + new person saves to global org
This commit is contained in:
parent
063c12d324
commit
23a009e60d
157
api/handlers.go
157
api/handlers.go
|
|
@ -1373,6 +1373,11 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
|
|||
result["linkedin"] = orgData.LinkedIn
|
||||
result["contact_name"] = orgData.ContactName
|
||||
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
|
||||
// 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) {
|
||||
actorID := UserIDFromContext(r.Context())
|
||||
orgID := chi.URLParam(r, "orgID")
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
|||
r.Get("/orgs", h.ListOrgs)
|
||||
r.Post("/orgs", h.CreateOrg)
|
||||
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.Delete("/orgs/{orgID}", h.DeleteOrg)
|
||||
r.Put("/admin/test-role", h.SetTestRole)
|
||||
|
|
|
|||
35
lib/types.go
35
lib/types.go
|
|
@ -64,23 +64,24 @@ const (
|
|||
|
||||
// OrgData is the JSON structure packed into an organization entry's Data field.
|
||||
type OrgData struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
|
||||
Role string `json:"role"` // seller | buyer | ib | advisor
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Industry string `json:"industry,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Fax string `json:"fax,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Founded string `json:"founded,omitempty"`
|
||||
LinkedIn string `json:"linkedin,omitempty"`
|
||||
ContactName string `json:"contact_name,omitempty"`
|
||||
ContactEmail string `json:"contact_email,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
|
||||
Role string `json:"role"` // seller | buyer | ib | advisor
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Industry string `json:"industry,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Fax string `json:"fax,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Founded string `json:"founded,omitempty"`
|
||||
LinkedIn string `json:"linkedin,omitempty"`
|
||||
ContactName string `json:"contact_name,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.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Organizations</h1>
|
||||
<p class="text-[#b0bec5] text-sm">Company directory — parties eligible to participate in deals.</p>
|
||||
<p class="text-[#b0bec5] text-sm">Company directory — parties eligible to participate in deals.</p>
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
</div>
|
||||
<div id="emptyState" class="hidden text-center py-20">
|
||||
<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>
|
||||
|
||||
|
|
@ -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>
|
||||
<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]">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -38,77 +37,90 @@
|
|||
</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 class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-6 pt-5 pb-4 border-b border-white/[0.08]">
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-2xl flex flex-col" style="max-height:90vh">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 pt-5 pb-0 shrink-0">
|
||||
<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">
|
||||
</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>
|
||||
<button onclick="closeEditModal()" class="text-[#b0bec5] hover:text-white text-2xl leading-none">×</button>
|
||||
</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="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 class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<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>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-[#b0bec5] mb-1">Description</label>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-[#b0bec5] mb-1">Industry</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>
|
||||
<div>
|
||||
<label class="block text-xs text-[#b0bec5] mb-1">Website</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]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[#b0bec5] mb-1">Phone</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]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[#b0bec5] mb-1">LinkedIn</label>
|
||||
<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 class="col-span-2"><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>
|
||||
<div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Description</label>
|
||||
<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><label class="block text-xs text-[#b0bec5] mb-1">Industry</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>
|
||||
<div><label class="block text-xs text-[#b0bec5] mb-1">Website</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]"></div>
|
||||
<div><label class="block text-xs text-[#b0bec5] mb-1">Phone</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]"></div>
|
||||
<div><label class="block text-xs text-[#b0bec5] mb-1">LinkedIn</label>
|
||||
<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><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">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 class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Email Domains</label>
|
||||
<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]">
|
||||
<p class="text-xs text-[#64748b] 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 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">×</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>
|
||||
<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>
|
||||
|
|
@ -123,9 +135,9 @@
|
|||
<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>
|
||||
<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>
|
||||
<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 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">
|
||||
|
|
@ -140,13 +152,6 @@
|
|||
<script>
|
||||
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() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/orgs');
|
||||
|
|
@ -162,18 +167,20 @@
|
|||
|
||||
grid.innerHTML = orgs.map(o => {
|
||||
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 || '';
|
||||
return `<div onclick="openEditModal('${o.entry_id}')"
|
||||
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">
|
||||
${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">${escHtml(name)}</h3>
|
||||
</div>
|
||||
${o.description ? `<p class="text-xs text-[#94a3b8] mb-2 line-clamp-2">${escHtml(o.description)}</p>` : ''}
|
||||
<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>
|
||||
${o.website ? `<div class="mt-2 text-xs text-[#64748b]">${escHtml(o.website)}</div>` : ''}
|
||||
</div>`;
|
||||
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">'
|
||||
+ '<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>' : '')
|
||||
+ '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>'
|
||||
+ '</div>'
|
||||
+ (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '')
|
||||
+ '<div class="flex items-center justify-between mt-1">'
|
||||
+ '<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>'
|
||||
+ (memberCount > 0 ? '<span class="text-xs text-[#94a3b8]">' + memberCount + ' member' + (memberCount !== 1 ? 's' : '') + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('orgGrid').innerHTML = '<div class="text-red-400 text-sm col-span-2">Failed to load.</div>';
|
||||
|
|
@ -207,34 +214,41 @@
|
|||
|
||||
// ---- Edit Org ----
|
||||
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) {
|
||||
const o = (window._orgsMap || {})[entryId];
|
||||
if (!o) return;
|
||||
editingOrgId = o.entry_id;
|
||||
editingMembers = (o.members || []).map(m => Object.assign({}, m));
|
||||
document.getElementById('eOrgId').value = o.entry_id;
|
||||
document.getElementById('eVersion').value = o.version || 1;
|
||||
document.getElementById('eName').value = o.name || '';
|
||||
document.getElementById('eDesc').value = o.description || '';
|
||||
|
||||
document.getElementById('eIndustry').value = o.industry || '';
|
||||
document.getElementById('eWebsite').value = o.website || '';
|
||||
document.getElementById('ePhone').value = o.phone || '';
|
||||
document.getElementById('eLinkedIn').value = o.linkedin || '';
|
||||
document.getElementById('eFounded').value = o.founded || '';
|
||||
document.getElementById('eAddress').value = o.address || '';
|
||||
document.getElementById('eCity').value = o.city || '';
|
||||
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('editModalError').classList.add('hidden');
|
||||
document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization';
|
||||
const logo = o.logo || '';
|
||||
if (logo) {
|
||||
document.getElementById('eLogoImg').src = logo;
|
||||
document.getElementById('eLogoWrap').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('eLogoWrap').classList.add('hidden');
|
||||
}
|
||||
if (logo) { document.getElementById('eLogoImg').src = logo; document.getElementById('eLogoWrap').classList.remove('hidden'); }
|
||||
else { document.getElementById('eLogoWrap').classList.add('hidden'); }
|
||||
switchEditTab('details');
|
||||
document.getElementById('rescrapeResults').classList.add('hidden');
|
||||
renderMemberList();
|
||||
document.getElementById('editOrgModal').classList.remove('hidden');
|
||||
document.getElementById('eName').focus();
|
||||
}
|
||||
|
|
@ -242,6 +256,7 @@
|
|||
function closeEditModal() {
|
||||
document.getElementById('editOrgModal').classList.add('hidden');
|
||||
editingOrgId = null;
|
||||
editingMembers = [];
|
||||
}
|
||||
|
||||
function previewEditLogo() {
|
||||
|
|
@ -261,12 +276,11 @@
|
|||
const domains = domainsRaw ? domainsRaw.split(',').map(d => d.trim().replace(/^@/, '')).filter(Boolean) : undefined;
|
||||
btn.disabled = true; btn.textContent = 'Saving...'; errEl.classList.add('hidden');
|
||||
try {
|
||||
const version = parseInt(document.getElementById('eVersion').value) || 1;
|
||||
const res = await fetchAPI('/api/orgs/' + editingOrgId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
domains,
|
||||
|
||||
name, domains,
|
||||
description: document.getElementById('eDesc').value.trim(),
|
||||
industry: document.getElementById('eIndustry').value.trim(),
|
||||
website: document.getElementById('eWebsite').value.trim(),
|
||||
|
|
@ -274,19 +288,150 @@
|
|||
linkedin: document.getElementById('eLinkedIn').value.trim(),
|
||||
founded: document.getElementById('eFounded').value.trim(),
|
||||
address: document.getElementById('eAddress').value.trim(),
|
||||
city: document.getElementById('eCity').value.trim(),
|
||||
state: document.getElementById('eState').value.trim(),
|
||||
logo: document.getElementById('eLogo').value.trim(),
|
||||
version: parseInt(document.getElementById('eVersion').value) || 1,
|
||||
version,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save');
|
||||
// Also save members separately
|
||||
await saveMembersToServer(data.version || version + 1);
|
||||
closeEditModal();
|
||||
loadOrgs();
|
||||
} 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 ----
|
||||
async function confirmDeleteOrg() {
|
||||
if (!editingOrgId) return;
|
||||
|
|
@ -296,21 +441,17 @@
|
|||
document.getElementById('deleteDealsWrap').classList.add('hidden');
|
||||
document.getElementById('dealsList').innerHTML = '';
|
||||
document.getElementById('deleteOrgModal').classList.remove('hidden');
|
||||
|
||||
// Fetch deals this org is in
|
||||
try {
|
||||
const res = await fetchAPI('/api/orgs/' + editingOrgId + '/deals');
|
||||
const deals = await res.json();
|
||||
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');
|
||||
}
|
||||
} catch(e) { /* non-fatal */ }
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteOrgModal').classList.add('hidden');
|
||||
}
|
||||
function closeDeleteModal() { document.getElementById('deleteOrgModal').classList.add('hidden'); }
|
||||
|
||||
async function executeDeleteOrg() {
|
||||
const btn = document.getElementById('confirmDeleteBtn');
|
||||
|
|
@ -319,9 +460,7 @@
|
|||
try {
|
||||
const res = await fetchAPI('/api/orgs/' + editingOrgId, { method: 'DELETE' });
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||||
closeDeleteModal();
|
||||
closeEditModal();
|
||||
loadOrgs();
|
||||
closeDeleteModal(); closeEditModal(); loadOrgs();
|
||||
} catch(e) {
|
||||
errEl.textContent = e.message; errEl.classList.remove('hidden');
|
||||
btn.disabled = false; btn.textContent = 'Delete permanently';
|
||||
|
|
|
|||
|
|
@ -153,6 +153,42 @@
|
|||
</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">×</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 -->
|
||||
<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]">
|
||||
|
|
@ -1372,7 +1408,7 @@
|
|||
<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))">
|
||||
${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>
|
||||
<span class="text-sm" style="color:var(--ds-tx3)">Add person</span>
|
||||
</div>
|
||||
|
|
@ -1979,6 +2015,152 @@
|
|||
});
|
||||
|
||||
// ---- 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 ----
|
||||
function openEditOrgModal(dealOrgId) {
|
||||
const o = _dealOrgsMap[dealOrgId];
|
||||
|
|
|
|||
Loading…
Reference in New Issue