feat: org management — Add Org modal with typeahead, members, permissions per org

This commit is contained in:
James 2026-03-07 13:50:03 -05:00
parent 375a2acf37
commit 27c682b960
6 changed files with 265 additions and 40 deletions

View File

@ -1559,6 +1559,8 @@ func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
dealOrgMap["org_id"] = dealOrgData.OrgID
dealOrgMap["role"] = dealOrgData.Role
dealOrgMap["domain_lock"] = dealOrgData.DomainLock
dealOrgMap["permissions"] = dealOrgData.Permissions
dealOrgMap["members"] = dealOrgData.Members
// Fetch org details
org, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)

Binary file not shown.

Binary file not shown.

BIN
dealspace

Binary file not shown.

View File

@ -75,9 +75,27 @@ type OrgData struct {
// DealOrgData is the JSON structure packed into a deal_org entry's Data field.
// A deal_org entry links an organization into a specific deal (project).
type DealOrgData struct {
OrgID string `json:"org_id"` // entry_id of the organization
Role string `json:"role"` // seller | buyer | ib | advisor
DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
OrgID string `json:"org_id"` // entry_id of the organization
Role string `json:"role"` // seller | buyer | ib | advisor
DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
Permissions *DealOrgPerms `json:"permissions,omitempty"`
Members []DealOrgMember `json:"members,omitempty"`
}
// DealOrgPerms defines what an org can do in a deal.
type DealOrgPerms struct {
Download string `json:"download"` // "full" | "watermark" | "none"
Upload bool `json:"upload"`
AddRequestLists bool `json:"add_request_lists"`
EditRequestLists bool `json:"edit_request_lists"`
FolderAccess string `json:"folder_access"` // "all" | "assigned"
}
// DealOrgMember is a person associated with a deal org.
type DealOrgMember struct {
Name string `json:"name"`
Email string `json:"email"`
Title string `json:"title,omitempty"`
}
// User represents an account.

View File

@ -672,59 +672,264 @@
}
// ---- Orgs + Team ----
// ---- Organizations ----
let allGlobalOrgs = [];
async function loadOrgs() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/orgs');
const orgs = await res.json();
const list = document.getElementById('orgList');
if (!orgs || orgs.length === 0) {
list.innerHTML = '<div class="text-[#94a3b8] text-sm">No organizations added yet.</div>';
} else {
list.innerHTML = orgs.map(o => {
const d = parseData(o.data_text);
const rc = roleColors[d.role] || 'bg-gray-500/20 text-gray-300';
return `<div class="flex items-center gap-4 px-5 py-4 rounded-xl bg-[#0d1f3c] border border-white/[0.08]">
<div class="flex-1">
list.innerHTML = '<div class="text-[#94a3b8] text-sm">No organizations added yet. Use "+ Add Org" to add parties to this deal.</div>';
return;
}
list.innerHTML = orgs.map(o => {
const rc = roleColors[o.role] || 'bg-gray-500/20 text-gray-300';
const roleLabel = { buyer: 'Buyer', seller: 'Seller', ib: 'IB Advisor', advisor: 'Advisor' }[o.role] || o.role || '?';
const perms = o.permissions || {};
const members = o.members || [];
const dlBadge = { full: '📥 Full download', watermark: '📥 Watermarked', none: '🚫 No download' }[perms.download] || '📥 Watermarked';
return `<div class="rounded-xl bg-[#0d1f3c] border border-white/[0.08] overflow-hidden">
<div class="flex items-center gap-4 px-5 py-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-white font-medium">${escHtml(d.org_name || d.name || 'Unknown')}</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium capitalize ${rc}">${d.role || '?'}</span>
<span class="text-white font-semibold">${escHtml(o.org_name || 'Unknown')}</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${rc}">${roleLabel}</span>
</div>
<div class="flex flex-wrap gap-2 text-xs text-[#475569] mt-1">
<span>${dlBadge}</span>
${perms.upload ? '<span>📤 Upload</span>' : ''}
${perms.add_request_lists ? '<span> Add lists</span>' : ''}
${perms.edit_request_lists ? '<span>✏️ Edit lists</span>' : ''}
<span>${perms.folder_access === 'all' ? '📂 All folders' : '📂 Assigned only'}</span>
</div>
${d.domains ? `<div class="flex gap-1.5 flex-wrap">${(Array.isArray(d.domains)?d.domains:[d.domains]).map(dm=>`<span class="text-xs text-[#94a3b8] font-mono bg-white/[0.05] px-2 py-0.5 rounded">@${dm}</span>`).join('')}</div>` : ''}
</div>
<button onclick="openPermModal('${o.deal_org_id}','${o.org_id}','${o.role}',${o.version || 1},${JSON.stringify(JSON.stringify(o.permissions||{}))})"
class="px-3 py-1.5 bg-white/[0.05] hover:bg-white/[0.08] text-[#94a3b8] hover:text-white rounded-lg text-xs transition shrink-0">⚙ Permissions</button>
</div>
${members.length > 0 ? `<div class="border-t border-white/[0.06] px-5 py-3">
<div class="flex flex-wrap gap-3">
${members.map(m => `<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-xs font-semibold">${(m.name||m.email||'?')[0].toUpperCase()}</div>
<div>
<div class="text-white text-xs font-medium">${escHtml(m.name||m.email)}</div>
${m.title ? `<div class="text-[#475569] text-xs">${escHtml(m.title)}</div>` : ''}
</div>
</div>`).join('')}
</div>
</div>` : ''}
</div>`;
}).join('');
} catch(e) { console.error(e); }
}
// ---- Add Org Modal ----
let orgSearchTimeout;
let memberCount = 0;
function openAddOrgModal() {
document.getElementById('addOrgModal').classList.remove('hidden');
document.getElementById('addOrgError').classList.add('hidden');
document.getElementById('addOrgName').value = '';
document.getElementById('addOrgId').value = '';
document.getElementById('memberRows').innerHTML = '';
memberCount = 0;
setOrgType('buyer', document.querySelector('[data-type="buyer"]'));
addMemberRow();
loadGlobalOrgs();
}
function closeAddOrgModal() {
document.getElementById('addOrgModal').classList.add('hidden');
document.getElementById('orgNameDropdown').classList.add('hidden');
}
async function loadGlobalOrgs() {
if (allGlobalOrgs.length) return;
try {
const res = await fetchAPI('/api/orgs');
allGlobalOrgs = await res.json() || [];
} catch(e) { allGlobalOrgs = []; }
}
function setOrgType(type, btn) {
document.querySelectorAll('.org-type-btn').forEach(b => {
b.classList.remove('border-[#c9a84c]', 'text-[#c9a84c]', 'bg-[#c9a84c]/10');
b.classList.add('border-white/[0.08]', 'text-[#94a3b8]');
});
if (btn) {
btn.classList.add('border-[#c9a84c]', 'text-[#c9a84c]', 'bg-[#c9a84c]/10');
btn.classList.remove('border-white/[0.08]', 'text-[#94a3b8]');
}
document.getElementById('addOrgType').value = type;
}
function orgNameSearch(q) {
clearTimeout(orgSearchTimeout);
const dd = document.getElementById('orgNameDropdown');
const orgId = document.getElementById('addOrgId');
orgId.value = '';
if (!q) { dd.classList.add('hidden'); return; }
orgSearchTimeout = setTimeout(() => {
const matches = allGlobalOrgs.filter(o => {
const d = parseData(o.data_text);
return (d.name || '').toLowerCase().includes(q.toLowerCase());
});
if (!matches.length) {
dd.innerHTML = `<div class="px-4 py-3 text-sm text-[#94a3b8]">No match — will create "<strong class="text-white">${escHtml(q)}</strong>" as new org</div>`;
} else {
dd.innerHTML = matches.map(o => {
const d = parseData(o.data_text);
const name = d.name || 'Unnamed';
return `<div onclick="selectOrg('${o.entry_id}','${escHtml(name).replace(/'/g,"\'")}','${d.role||''}',this)"
class="px-4 py-3 text-sm text-white hover:bg-white/[0.06] cursor-pointer flex items-center gap-3">
<span class="font-medium">${escHtml(name)}</span>
<span class="text-xs text-[#475569] capitalize">${d.role || ''}</span>
</div>`;
}).join('');
}
loadTeam();
} catch(e) {}
dd.classList.remove('hidden');
}, 200);
}
async function loadTeam() {
function selectOrg(orgId, orgName, role, el) {
document.getElementById('addOrgId').value = orgId;
document.getElementById('addOrgName').value = orgName;
if (role) setOrgType(role, document.querySelector(`[data-type="${role}"]`));
document.getElementById('orgNameDropdown').classList.add('hidden');
}
function addMemberRow() {
const idx = memberCount++;
const row = document.createElement('div');
row.className = 'grid grid-cols-[1fr_1fr_1fr_auto] gap-2 items-center';
row.id = 'member-row-' + idx;
row.innerHTML = `
<input type="text" placeholder="Name" id="mname-${idx}"
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<input type="email" placeholder="Email" id="memail-${idx}"
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<input type="text" placeholder="Title" id="mtitle-${idx}"
class="px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] text-xs focus:outline-none focus:border-[#c9a84c]">
<button onclick="document.getElementById('member-row-${idx}').remove()" class="text-[#475569] hover:text-red-400 transition text-lg leading-none">&times;</button>`;
document.getElementById('memberRows').appendChild(row);
}
async function submitAddOrg() {
const name = document.getElementById('addOrgName').value.trim();
const existingOrgId = document.getElementById('addOrgId').value;
const role = document.getElementById('addOrgType').value;
const errEl = document.getElementById('addOrgError');
const btn = document.getElementById('addOrgSubmitBtn');
if (!name) { errEl.textContent = 'Organization name is required'; errEl.classList.remove('hidden'); return; }
// Collect members
const members = [];
document.querySelectorAll('#memberRows > div').forEach(row => {
const idx = row.id.replace('member-row-', '');
const n = document.getElementById('mname-' + idx)?.value.trim();
const e = document.getElementById('memail-' + idx)?.value.trim();
const t = document.getElementById('mtitle-' + idx)?.value.trim();
if (n || e) members.push({ name: n || '', email: e || '', title: t || '' });
});
const perms = {
download: document.getElementById('permDownload').value,
upload: document.getElementById('permUpload').checked,
add_request_lists: document.getElementById('permAddLists').checked,
edit_request_lists: document.getElementById('permEditLists').checked,
folder_access: document.getElementById('permFolderAccess').value,
};
btn.disabled = true; btn.textContent = 'Adding...'; errEl.classList.add('hidden');
try {
const res = await fetchAPI('/api/projects/' + projectID + '/members');
const members = await res.json();
const section = document.getElementById('teamMembersSection');
if (!section) return;
if (!members || members.length === 0) {
section.innerHTML = `<div class="border-t border-white/[0.08] pt-4 mt-4">
<h3 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-3">Team Members</h3>
<div class="text-[#94a3b8] text-sm">No team members yet.</div>
</div>`;
return;
let orgId = existingOrgId;
if (!orgId) {
// Create new global org first
const createRes = await fetchAPI('/api/orgs', { method: 'POST', body: JSON.stringify({ name, domains: [], role, website: '' }) });
if (!createRes.ok) { const e = await createRes.json(); throw new Error(e.error || 'Failed to create org'); }
const created = await createRes.json();
orgId = created.entry_id || created.org_id;
}
section.innerHTML = `<div class="border-t border-white/[0.08] pt-4 mt-4">
<h3 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-3">Team Members</h3>
<div class="space-y-2">${members.map(m => `
<div class="flex items-center gap-4 px-5 py-3 rounded-xl bg-[#0d1f3c] border border-white/[0.08]">
<div class="w-8 h-8 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] font-semibold text-sm">${(m.name||m.email||'?')[0].toUpperCase()}</div>
<div class="flex-1">
<div class="text-white text-sm font-medium">${escHtml(m.name || m.email)}</div>
${m.name ? `<div class="text-[#94a3b8] text-xs">${escHtml(m.email)}</div>` : ''}
</div>
<span class="text-xs text-[#94a3b8] capitalize">${m.role || 'member'}</span>
</div>`).join('')}
</div>
</div>`;
} catch(e) {}
// Add to deal
const addRes = await fetchAPI('/api/projects/' + projectID + '/orgs', {
method: 'POST', body: JSON.stringify({ org_id: orgId, role, domain_lock: false })
});
if (!addRes.ok) { const e = await addRes.json(); throw new Error(e.error || 'Failed to add org to deal'); }
const added = await addRes.json();
// Update with permissions + members
if (added.deal_org_id) {
await fetchAPI('/api/projects/' + projectID + '/entries/' + added.deal_org_id, {
method: 'PUT',
body: JSON.stringify({
project_id: projectID, parent_id: projectID, type: 'deal_org', depth: 1,
data: JSON.stringify({ org_id: orgId, role, domain_lock: false, permissions: perms, members }),
version: 1
})
});
}
closeAddOrgModal();
loadOrgs();
} catch(e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Add to Deal';
}
}
// ---- Permissions Modal ----
function openPermModal(dealOrgId, orgId, role, version, permsJson) {
let perms = {};
try { perms = JSON.parse(permsJson) || {}; } catch(e) {}
document.getElementById('permModalDealOrgId').value = dealOrgId;
document.getElementById('permModalOrgId').value = orgId;
document.getElementById('permModalRole').value = role;
document.getElementById('permModalVersion').value = version;
const roleLabel = { buyer: 'Buyer', seller: 'Seller', ib: 'IB Advisor', advisor: 'Advisor' }[role] || role;
document.getElementById('permModalTitle').textContent = 'Permissions — ' + roleLabel;
document.getElementById('pmDownload').value = perms.download || 'watermark';
document.getElementById('pmUpload').checked = !!perms.upload;
document.getElementById('pmAddLists').checked = !!perms.add_request_lists;
document.getElementById('pmEditLists').checked = !!perms.edit_request_lists;
document.getElementById('pmFolderAccess').value = perms.folder_access || 'assigned';
document.getElementById('permModal').classList.remove('hidden');
}
function closePermModal() { document.getElementById('permModal').classList.add('hidden'); }
async function savePermissions() {
const dealOrgId = document.getElementById('permModalDealOrgId').value;
const orgId = document.getElementById('permModalOrgId').value;
const role = document.getElementById('permModalRole').value;
const version = parseInt(document.getElementById('permModalVersion').value) || 1;
const perms = {
download: document.getElementById('pmDownload').value,
upload: document.getElementById('pmUpload').checked,
add_request_lists: document.getElementById('pmAddLists').checked,
edit_request_lists: document.getElementById('pmEditLists').checked,
folder_access: document.getElementById('pmFolderAccess').value,
};
try {
await fetchAPI('/api/projects/' + projectID + '/entries/' + dealOrgId, {
method: 'PUT',
body: JSON.stringify({
project_id: projectID, parent_id: projectID, type: 'deal_org', depth: 1,
data: JSON.stringify({ org_id: orgId, role, domain_lock: false, permissions: perms }),
version
})
});
closePermModal();
loadOrgs();
} catch(e) { alert('Failed to save permissions'); }
}
function openPermissionsTemplateModal() {
alert('Coming soon: set default permissions templates per org type for all deals.');
}
function switchTab(name, el) {