diff --git a/api/handlers.go b/api/handlers.go index d7a1231..32f2b6a 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -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) diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 861b086..9d3150e 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/data/dealspace.db-wal b/data/dealspace.db-wal index f59344e..2d8da98 100644 Binary files a/data/dealspace.db-wal and b/data/dealspace.db-wal differ diff --git a/dealspace b/dealspace index 97d82ea..5a9e0ba 100755 Binary files a/dealspace and b/dealspace differ diff --git a/lib/types.go b/lib/types.go index fe14e2a..a9e2ce5 100644 --- a/lib/types.go +++ b/lib/types.go @@ -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. diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index a0539b8..3a52cb9 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -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 = '
No organizations added yet.
'; - } 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 `
-
+ list.innerHTML = '
No organizations added yet. Use "+ Add Org" to add parties to this deal.
'; + 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 `
+
+
- ${escHtml(d.org_name || d.name || 'Unknown')} - ${d.role || '?'} + ${escHtml(o.org_name || 'Unknown')} + ${roleLabel} +
+
+ ${dlBadge} + ${perms.upload ? '📤 Upload' : ''} + ${perms.add_request_lists ? '➕ Add lists' : ''} + ${perms.edit_request_lists ? '✏️ Edit lists' : ''} + ${perms.folder_access === 'all' ? '📂 All folders' : '📂 Assigned only'}
- ${d.domains ? `
${(Array.isArray(d.domains)?d.domains:[d.domains]).map(dm=>`@${dm}`).join('')}
` : ''}
+ +
+ ${members.length > 0 ? `
+
+ ${members.map(m => `
+
${(m.name||m.email||'?')[0].toUpperCase()}
+
+
${escHtml(m.name||m.email)}
+ ${m.title ? `
${escHtml(m.title)}
` : ''} +
+
`).join('')} +
+
` : ''} +
`; + }).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 = `
No match — will create "${escHtml(q)}" as new org
`; + } else { + dd.innerHTML = matches.map(o => { + const d = parseData(o.data_text); + const name = d.name || 'Unnamed'; + return `
+ ${escHtml(name)} + ${d.role || ''}
`; }).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 = ` + + + + `; + 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 = `
-

Team Members

-
No team members yet.
-
`; - 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 = `
-

Team Members

-
${members.map(m => ` -
-
${(m.name||m.email||'?')[0].toUpperCase()}
-
-
${escHtml(m.name || m.email)}
- ${m.name ? `
${escHtml(m.email)}
` : ''} -
- ${m.role || 'member'} -
`).join('')} -
-
`; - } 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) {