feat: org management — Add Org modal with typeahead, members, permissions per org
This commit is contained in:
parent
375a2acf37
commit
27c682b960
|
|
@ -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.
18
lib/types.go
18
lib/types.go
|
|
@ -78,6 +78,24 @@ 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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 = '<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 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">
|
||||
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>
|
||||
${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 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>
|
||||
</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() {
|
||||
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;
|
||||
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');
|
||||
}
|
||||
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) {}
|
||||
|
||||
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">×</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 {
|
||||
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;
|
||||
}
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue