function createOrgChartView({ environment, tableSorting, model, loadLayout, icon, searchableSelect, request, requestQueryAccountContact, requestPersistContact, requestOpportunitiesByContact, getPrincipal, modalId, notify }) { const { document } = environment; let active = null; const nodeWidth = 236, nodeHeight = 146; function el(tag, className = "", text) { const element = document.createElement(tag); element.className = className; if (text !== undefined) element.textContent = text; return element; } function button(label, symbol, action, text = false) { const item = el("button", "oe-org-button"); item.type = "button"; item.title = label; item.setAttribute("aria-label", label); item.append(icon(symbol)); if (text) item.append(el("span", "", label)); item.addEventListener("click", action); return item; } function createButton(opportunity) { const item = button("View customer organization chart", "chart", () => open(opportunity, item)); item.className = "opportunities-extension-action-button"; item.setAttribute("aria-haspopup", "dialog"); return item; } function close(force = false) { if (!active) return false; const entry = active; if (entry.saving) return true; if (!force && entry.hasChanges()) { entry.confirm("Discard unsaved changes?", "Your organization chart has unsaved changes.", "Discard changes", () => close(true)); return true; } entry.contactCleanup?.(); active = null; entry.edit?.nameCombo.destroy(); entry.observer?.disconnect(); entry.modal.remove(); entry.siblings.forEach(([element, inert]) => { element.inert = inert; }); if (entry.trigger?.isConnected) entry.trigger.focus(); return true; } function open(opportunity, trigger) { if (active) return; const host = document.getElementById(modalId); if (!host || host.hidden) return; const customer = opportunity.CustomerAccount || {}; const customerId = String(customer.PartyId || customer.PartyNumber || ""), principal = getPrincipal(); const modal = el("section", "opportunities-extension-action-form-modal oe-org-modal"); modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-labelledby", "oe-org-title"); modal.setAttribute("lang", "en"); const dialog = el("div", "opportunities-extension-action-form-dialog oe-org-dialog"); const header = el("header", "opportunities-extension-account-plans-header"), heading = el("div", "oe-org-heading"); const title = el("h2", "", "Organization chart"); title.id = "oe-org-title"; heading.append(title, el("p", "", customer.PartyUniqueName || "Customer")); const dismiss = button("Close organization chart", "close", () => close()); dismiss.className = "opportunities-extension-icon-button"; header.append(heading, dismiss); const toolbar = el("div", "oe-org-toolbar"), tools = el("div", "oe-org-tools"), count = el("span", "oe-org-count"); const add = button("Add person", "plus", () => guardEditor(() => editPerson(null)), true); const expand = button("Expand all", "expand", () => foldAll(false)), collapse = button("Collapse all", "collapse", () => foldAll(true)); const zoomOut = button("Zoom out", "minus", () => setZoom(entry.zoom - .1)), zoomIn = button("Zoom in", "plus", () => setZoom(entry.zoom + .1)); const zoomValue = el("output", "oe-org-zoom", "100%"); zoomValue.setAttribute("aria-label", "Zoom level"); const fitButton = button("Fit chart to view", "fit", fit); tools.append(expand, collapse, zoomOut, zoomValue, zoomIn, fitButton); toolbar.append(add, count, tools); const workspace = el("div", "oe-org-workspace"), viewport = el("div", "oe-org-viewport"); viewport.tabIndex = 0; viewport.setAttribute("role", "region"); viewport.setAttribute("aria-label", "Organization chart canvas"); const sizing = el("div", "oe-org-sizing"), canvas = el("div", "oe-org-canvas"); sizing.append(canvas); viewport.append(sizing); const empty = el("div", "oe-org-empty"); empty.append(icon("chart"), el("h3", "", "No people yet"), button("Add first person", "plus", () => editPerson(null), true)); viewport.append(empty); const editor = el("aside", "oe-org-editor"); editor.hidden = true; editor.setAttribute("aria-label", "Person details"); workspace.append(viewport, editor); const status = el("p", "oe-org-status", "Loading organization chart..."); status.setAttribute("role", "status"); const retry = button("Retry loading", "plus", load, true); retry.hidden = true; const footer = el("footer", "oe-org-footer"), save = button("Save chart", "save", saveChart, true); save.classList.add("oe-org-primary"); footer.append(status, retry, save); dialog.append(header, toolbar, workspace, footer); modal.append(dialog); const tooltip = el("div", "oe-org-tooltip"); tooltip.hidden = true; tooltip.id = "oe-org-notes-tooltip"; tooltip.setAttribute("role", "tooltip"); modal.append(tooltip); const siblings = Array.from(host.children).map(element => [element, element.inert]); siblings.forEach(([element]) => { element.inert = true; }); host.append(modal); const entry = { modal, trigger, siblings, nodes: [], baseline: "[]", revision: 0, ready: false, saving: false, zoom: 1, autoFit: true, width: 1, height: 1, selected: null, edit: null, confirmation: null, contacts: [], details: new Map() }; entry.hasChanges = () => JSON.stringify(entry.nodes) !== entry.baseline || editorDirty(); entry.confirm = confirm; active = entry; function message(text, error = false) { status.textContent = text; status.setAttribute("role", error ? "alert" : "status"); status.classList.toggle("oe-org-error", error); } function updateControls() { const blocked = !entry.ready || entry.saving; for (const control of [add, expand, collapse, zoomOut, zoomIn, fitButton]) control.disabled = blocked || (control !== add && !entry.nodes.length); save.disabled = blocked || !entry.hasChanges(); dismiss.disabled = entry.saving; toolbar.inert = workspace.inert = entry.saving; } function changed() { message("Unsaved changes"); updateControls(); } function editorValue() { if (!entry.edit) return null; const { fields, person } = entry.edit; const contact = entry.contacts.find(item => item.PartyId === fields.name.value); return { ...person, ...(contact ? { contactPartyId: contact.PartyId, PartyNumber: contact.PartyNumber || (contact.PartyId === person.contactPartyId ? person.PartyNumber : undefined) } : {}), name: contact ? contact.PartyName : fields.name.value === "__saved" ? person.name : "", jobTitle: contact ? (typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : "") : fields.name.value === "__saved" ? person.jobTitle || "" : "", notes: fields.notes.value, parentId: fields.parent.value === "__root" ? null : fields.parent.value }; } function editorDirty() { return !!entry.edit && JSON.stringify(editorValue()) !== entry.edit.baseline; } function guardEditor(action) { if (entry.saving) return; if (editorDirty()) confirm("Discard person edits?", "This person has unapplied changes.", "Discard edits", () => { entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; action(); }); else action(); } function confirm(titleText, bodyText, label, action) { if (entry.confirmation) return; const overlay = el("div", "oe-org-confirm"), panel = el("section", "oe-org-confirm-panel"); panel.setAttribute("role", "alertdialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-org-confirm-title"); panel.setAttribute("aria-describedby", "oe-org-confirm-body"); const heading = el("h3", "", titleText); heading.id = "oe-org-confirm-title"; const body = el("p", "", bodyText); body.id = "oe-org-confirm-body"; const actions = el("div", "oe-org-form-actions"), previous = document.activeElement; function finish() { entry.confirmation = null; overlay.remove(); dialog.inert = false; previous?.focus(); } const cancel = button("Keep editing", "close", finish, true), proceed = button(label, "check", () => { finish(); action(); }, true); actions.append(cancel, proceed); panel.append(heading, body, actions); overlay.append(panel); modal.append(overlay); entry.confirmation = overlay; dialog.inert = true; tooltip.hidden = true; overlay.addEventListener("keydown", event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); } if (event.key === "Tab") { event.preventDefault(); (document.activeElement === cancel ? proceed : cancel).focus(); } }); cancel.focus(); } function discardEditor() { entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = null; render(); updateControls(); } function deletePerson(person) { confirm("Delete person?", "Delete " + person.name + "? Direct reports will move to this person's manager.", "Delete person", () => { entry.nodes = model.remove(entry.nodes, person.id); entry.edit?.nameCombo.destroy(); entry.edit = null; entry.selected = null; editor.hidden = true; changed(); render(); add.focus(); }); } function showContactOpportunities(person, trigger) { if (entry.confirmation) return; person = entry.nodes.find(node => node.id === person.id) || person; const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-opportunities"); panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-opportunities-title"); const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities — " + person.name); title.id = "oe-contact-opportunities-title"; let closed = false, offset = 0, partyNumber = person.PartyNumber; const current = () => !closed && active === entry; function finish() { closed = true; overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); } header.append(title, button("Close opportunities", "close", finish)); const status = el("p", "oe-org-status"), surface = el("div", "oe-contact-opportunities-table"), table = el("table"), head = el("thead"), row = el("tr"), body = el("tbody"); status.setAttribute("role", "status"); const columns = [ { key: "OptyNumber", label: "OptyNumber" }, { key: "WinProb", label: "Win Probability", type: "number" }, { key: "Name", label: "Name" }, { key: "CurrencyCode", label: "CurrencyCode" }, { key: "Revenue", label: "Amount", type: "number" }, { key: "EffectiveDate", label: "Close date", type: "date" }, { key: "StatusCode", label: "StatusCode" } ]; const sorting = tableSorting.create("contact-opportunities", columns); let allItems = [], loading = false; for (const column of columns) { const th = el("th"); th.scope = "col"; sorting.header(th, column.key, column.label, renderRows); row.append(th); } head.append(row); table.append(head, body); surface.append(table); const footer = el("footer", "oe-org-form-actions"); const retry = button("Retry", "plus", () => load(), true); footer.append(retry); panel.append(header, status, surface, footer); overlay.append(panel); modal.append(overlay); entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true; overlay.addEventListener("keydown", event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); } if (event.key === "Tab") { const controls = [...panel.querySelectorAll("button:not(:disabled), a")].filter(item => item.getClientRects().length); const first = controls[0], last = controls.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }); function renderRows() { body.replaceChildren(); table.setAttribute("aria-busy", String(loading)); for (const button of head.querySelectorAll("button")) button.disabled = loading; if (loading) { for (let index = 0; index < 5; index++) { const row = el("tr"); row.setAttribute("aria-hidden", "true"); for (const column of columns) { const cell = el("td"), bar = el("span", "oe-contact-skeleton"); bar.style.width = column.key === "Name" ? "90%" : column.key === "WinProb" || column.key === "StatusCode" ? "45%" : "70%"; cell.append(bar); row.append(cell); } body.append(row); } return; } for (const item of sorting.select(allItems)) { const row = el("tr"); for (const key of ["OptyNumber", "WinProb", "Name", "CurrencyCode", "Revenue", "EffectiveDate", "StatusCode"]) { const cell = el("td"); let value = item[key] ?? "—"; if (key === "Name" && item.OptyNumber) { const link = el("a", "", String(value)); link.href = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=" + encodeURIComponent(item.OptyNumber); link.target = "_blank"; link.rel = "noopener noreferrer"; cell.append(link); } else { if (key === "WinProb" && item[key] != null) value += "%"; if (key === "Revenue" && item[key] != null && Number.isFinite(Number(value))) value = Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (key === "EffectiveDate" && /^\d{4}-\d{2}-\d{2}/.test(String(value))) value = String(value).slice(0, 10).split("-").reverse().join("/"); cell.textContent = String(value); } row.append(cell); } body.append(row); } } async function load() { offset = 0; retry.hidden = true; status.classList.remove("oe-org-error"); status.textContent = ""; status.setAttribute("aria-label", "Loading opportunities"); const totalSkeleton = el("span", "oe-contact-skeleton oe-contact-skeleton-total"); totalSkeleton.setAttribute("aria-hidden", "true"); status.append(totalSkeleton); let loaded = 0; allItems = []; loading = true; renderRows(); try { while (current()) { const data = await requestOpportunitiesByContact(partyNumber, offset, { contactPartyId: person.contactPartyId, contactName: person.name, isActive: current, async resolvePartyNumber() { // Read the saved person, never replace a persisted identifier with a search result. const chart = await request("load", { principal, customerId }); if (!current()) return; const saved = chart.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId); partyNumber = saved?.PartyNumber; if (!partyNumber) throw new Error("This person has no PartyNumber saved in the organization chart. Open Person details to retrieve and save it before viewing opportunities."); const currentPerson = entry.nodes.find(node => node.id === person.id); if (currentPerson) entry.nodes = model.upsert(entry.nodes, { ...currentPerson, PartyNumber: partyNumber }); return partyNumber; } }); if (!current()) return; allItems.push(...data.items); loaded += data.items.length; if (!data.hasMore) { status.textContent = loaded ? `${loaded} opportunities` : "No opportunities found."; break; } const count = Number(data.count ?? data.items.length), responseOffset = Number(data.offset ?? offset); const nextOffset = responseOffset + count; if (!data.items.length || !Number.isSafeInteger(nextOffset) || count <= 0 || nextOffset <= offset) throw new Error("The API returned hasMore without advancing the page. Please retry."); offset = nextOffset; status.setAttribute("aria-label", `Loading opportunities: ${loaded} loaded`); } } catch (error) { if (current()) { status.textContent = error.message; status.classList.add("oe-org-error"); retry.hidden = false; } } finally { if (current()) { loading = false; status.removeAttribute("aria-label"); renderRows(); } } } header.querySelector("button").focus(); load(); } function createContact(trigger) { if (entry.confirmation) return; const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-dialog"); panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-title"); const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Create Contact"); title.id = "oe-contact-title"; const form = el("form", "oe-contact-form"), combos = []; form.noValidate = true; let validationShown = false; let saving = false; function finish() { if (saving) return; combos.forEach(combo => combo.destroy()); overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); } header.append(title, button("Close Create Contact", "close", finish)); for (const [label, type, autocomplete] of [["First Name", "text", "given-name"], ["Last Name", "text", "family-name"], ["Job Title", "text", "organization-title"], ["Email Address", "email", "email"]]) { const field = el("label", "oe-org-field", label), input = el("input"); input.type = type; input.autocomplete = autocomplete; input.name = autocomplete; if (["given-name", "family-name", "email"].includes(autocomplete)) { input.required = true; field.append(document.createTextNode(" (required)")); } else field.append(document.createTextNode(" (optional)")); input.setAttribute("aria-label", label); field.append(input); form.append(field); } function consent(parent, name, label, verb) { const group = el("fieldset", "oe-contact-primary"); group.append(el("legend", "", label)); for (const [value, text] of [["none", "None"], ["deny", "Do Not " + verb], ["allow", "Ok to " + verb]]) { const field = el("label"), radio = el("input"); radio.type = "radio"; radio.name = name; radio.value = value; radio.checked = value === "none"; field.append(radio, document.createTextNode(text)); group.append(field); } parent.append(group); } consent(form, "emailConsent", "Email preference", "Email"); const names = new Intl.DisplayNames(["en"], { type: "region" }); const countries = orgChartCallingCodes.map(([code, dial]) => ({ code, dial, name: names.of(code) })).sort((a, b) => a.name.localeCompare(b.name, "en")); for (const label of ["Work Phone", "Mobile Phone"]) { const group = el("fieldset", "oe-contact-phone"), legend = el("legend", "", label); const countryField = el("label", "oe-org-field", "Country code"), select = el("select"); const placeholder = el("option", "", "Select country code"); placeholder.value = ""; select.append(placeholder); for (const country of countries) { const option = el("option", "", country.code + " · " + country.name + " (+" + country.dial + ")"); option.value = country.code; option.dataset.callingCode = country.dial; select.append(option); } select.name = label === "Work Phone" ? "workCountry" : "mobileCountry"; select.id = "oe-contact-" + select.name; countryField.append(select); const numberField = el("label", "oe-org-field", "Phone number"), input = el("input"); input.type = "tel"; input.autocomplete = "tel-national"; input.name = label === "Work Phone" ? "workPhone" : "mobilePhone"; input.setAttribute("aria-label", label); numberField.append(input); group.append(legend, countryField, numberField); form.append(group); if (label === "Work Phone") { const extension = el("label", "oe-org-field", "Extension (optional)"), input = el("input"); input.name = "workExtension"; input.type = "tel"; extension.append(input); group.append(extension); } consent(group, label === "Work Phone" ? "workConsent" : "mobileConsent", "Contact preference", "Call"); function validatePhone() { input.setCustomValidity(select.value && !input.value.trim() ? "Enter the phone number." : ""); combo.input.setCustomValidity(input.value.trim() && !select.value ? "Select a country code." : ""); } const combo = searchableSelect(select, countryField, validatePhone, { floating: true, listWidth: 390, listMaxHeight: 260 }); input.addEventListener("input", validatePhone); combo.input.setAttribute("aria-label", label + " country code"); combos.push(combo); } const result = el("p", "oe-org-status"); result.setAttribute("role", "status"); result.id = "oe-contact-validation"; function validateContact(focus = false) { const invalid = []; for (const input of form.querySelectorAll("input:not([type=radio])")) { const valid = input.validity.valid && (!input.required || !!input.value.trim()); input.setAttribute("aria-invalid", String(!valid)); if (!valid) { input.setAttribute("aria-describedby", result.id); invalid.push(input); } else input.removeAttribute("aria-describedby"); } result.classList.toggle("oe-org-error", invalid.length > 0); result.setAttribute("role", invalid.length ? "alert" : "status"); result.textContent = invalid.length ? "Complete the highlighted required fields with valid values." : ""; if (focus) invalid[0]?.focus(); return !invalid.length; } form.addEventListener("input", () => { if (validationShown && !saving) validateContact(); }); form.addEventListener("change", () => { if (validationShown && !saving) validateContact(); }); const actions = el("footer", "oe-org-form-actions"), saveContact = button("Save", "save", () => form.requestSubmit(), true); saveContact.classList.add("oe-org-primary"); actions.append(button("Cancel", "close", finish, true), saveContact); form.addEventListener("submit", async event => { event.preventDefault(); if (saving) return; validationShown = true; if (!validateContact(true)) return; const value = name => form.elements.namedItem(name)?.value || ""; const country = name => form.elements.namedItem(name).selectedOptions[0]?.dataset.callingCode || ""; const values = { firstName: value("given-name"), lastName: value("family-name"), jobTitle: value("organization-title"), email: value("email"), emailConsent: value("emailConsent"), workConsent: value("workConsent"), mobileConsent: value("mobileConsent"), workPhone: value("workPhone"), mobilePhone: value("mobilePhone"), workCountry: country("workCountry"), mobileCountry: country("mobileCountry"), workExtension: value("workExtension") }; saving = true; saveContact.disabled = true; form.inert = true; result.classList.remove("oe-org-error"); result.textContent = "Saving contact…"; try { const data = await requestPersistContact(customer, values); saving = false; if (active !== entry) return; finish(); notify("Contact created successfully."); if (entry.edit && data?.PartyId != null) { const contact = { PartyId: String(data.PartyId), PartyNumber: data.PartyNumber, PartyName: data.PartyName || [values.firstName, values.lastName].join(" "), JobTitle: data.JobTitle ?? values.jobTitle, PrimaryEmail: { EmailAddress: data.EmailAddress ?? values.email } }; entry.contacts.push(contact); const option = el("option", "", contact.PartyName); option.value = contact.PartyId; entry.edit.fields.name.append(option); entry.edit.fields.name.value = contact.PartyId; entry.edit.nameCombo.sync(); showContactDetails(entry.edit, contact); updateControls(); } } catch (error) { if (active === entry) { result.textContent = error.message || "Unable to create contact."; result.classList.add("oe-org-error"); notify(result.textContent, true); } } finally { saving = false; saveContact.disabled = false; form.inert = false; } }); panel.append(header, form, result, actions); overlay.append(panel); modal.append(overlay); entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true; overlay.addEventListener("keydown", event => { if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); finish(); } if (event.key === "Tab") { const controls = [...panel.querySelectorAll("button, input")].filter(item => !item.disabled && item.getClientRects().length); const first = controls[0], last = controls.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }); form.querySelector("input").focus(); } function editPerson(person, parentId = null) { entry.edit?.nameCombo.destroy(); tooltip.hidden = true; const existing = person || { id: environment.crypto.randomUUID(), parentId, name: "", notes: "", collapsed: false }; entry.selected = person?.id || null; editor.replaceChildren(); editor.hidden = false; const editorHeader = el("div", "oe-org-editor-header"); editorHeader.append(el("h3", "", person ? "Person details" : "Add person"), button("Close person details", "close", () => guardEditor(discardEditor))); const form = el("form", "oe-org-form"), fields = {}; const nameField = el("label", "oe-org-field", "Name *"), nameSelect = el("select"); nameSelect.id = "oe-org-name"; nameSelect.required = true; fields.name = nameSelect; nameField.append(nameSelect); form.append(nameField); const placeholder = el("option", "", "Type to search contacts"); placeholder.value = ""; nameSelect.append(placeholder); if (existing.name) { const saved = el("option", "", existing.name); saved.value = "__saved"; nameSelect.append(saved); nameSelect.value = "__saved"; } const nameCombo = searchableSelect(nameSelect, nameField, () => { nameCombo.input.setCustomValidity(""); entry.edit.detailsVersion++; const contact = entry.contacts.find(item => item.PartyId === fields.name.value); showContactDetails(entry.edit, contact); updateControls(); }, { floating: true, listMaxHeight: 280, searchDelay: 350, async search(keywords, isCurrent) { const contacts = await requestQueryAccountContact(customer.PartyId, keywords, () => active === entry && isCurrent()); if (!isCurrent() || active !== entry) return []; entry.contacts = contacts; return contacts.map(contact => ({ value: contact.PartyId, label: contact.PartyName, description: contact.JobTitle || "" })); } }); nameCombo.input.setAttribute("aria-label", "Name"); const create = button("Create Contact", "contact", event => { event.preventDefault(); createContact(create); }); create.setAttribute("aria-haspopup", "dialog"); const wrapper = nameCombo.input.parentElement; wrapper.classList.add("oe-contact-name"); wrapper.append(create); for (const [key, label, max] of [["role", "Job Title", 160], ["emailAddress", "Email address", 320], ["notes", "Notes", 4000]]) { const field = el("label", "oe-org-field", label + (key === "notes" ? " (optional)" : " (from contact)")); const input = el(key === "notes" ? "textarea" : "input"); input.id = "oe-org-" + key; input.value = (key === "role" ? existing.jobTitle : existing[key]) || ""; input.maxLength = max; input.setAttribute("aria-label", label); if (key === "notes") input.rows = 7; else { input.readOnly = true; input.placeholder = "Not provided"; } field.append(input); fields[key] = input; form.append(field); input.addEventListener("input", () => { input.setCustomValidity(""); updateControls(); }); } const detailStatus = el("p", "oe-org-status"); detailStatus.setAttribute("role", "status"); const detailRetry = button("Retry contact details", "plus", () => loadPersonDetails(entry.edit), true); detailRetry.hidden = true; form.append(detailStatus, detailRetry); const reports = el("label", "oe-org-field", "Reports to"), select = el("select"); select.id = "oe-org-parent"; const top = el("option", "", "No manager (top level)"); top.value = "__root"; select.append(top); const excluded = person ? model.descendants(entry.nodes, person.id) : new Set(); for (const candidate of entry.nodes.filter(item => !excluded.has(item.id))) { const title = candidate.jobTitle; const option = el("option", "", candidate.name + (title ? " - " + title : "")); option.value = candidate.id; select.append(option); } select.value = existing.parentId || "__root"; fields.parent = select; reports.append(select); form.append(reports); const combo = searchableSelect(select, reports, updateControls, { floating: true }); combo.input.setAttribute("aria-label", "Reports to"); const actions = el("div", "oe-org-form-actions"); const apply = button(person ? "Apply changes" : "Add person", "check", () => {}, true); apply.type = "submit"; apply.classList.add("oe-org-primary"); actions.append(button("Cancel", "close", discardEditor, true), apply); if (person) { const remove = button("Delete person", "trash", () => deletePerson(person), true); remove.classList.add("oe-org-danger"); form.append(remove); } form.append(actions); editor.append(editorHeader, form); entry.edit = { person: existing, fields, form, combo, nameCombo, baseline: "", detailsVersion: 0, detailStatus, detailRetry }; entry.edit.baseline = JSON.stringify(editorValue()); form.addEventListener("submit", event => { event.preventDefault(); if (applyEditor()) { render(); focusPerson(entry.selected); } }); render(); updateControls(); if (!nameCombo.input.disabled) nameCombo.input.focus(); else fields.role.focus(); if (person) loadPersonDetails(entry.edit); } function showContactDetails(edit, contact) { edit.fields.role.value = typeof contact?.JobTitle === "string" ? contact.JobTitle : ""; edit.fields.emailAddress.value = typeof contact?.PrimaryEmail?.EmailAddress === "string" ? contact.PrimaryEmail.EmailAddress : ""; for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Not provided"; edit.detailStatus.textContent = ""; edit.detailRetry.hidden = true; if (contact) entry.details.set(String(contact.PartyId), contact); render(); } async function persistPartyNumber(person, value) { const number = String(value).trim(); if (!/^[A-Za-z0-9]{1,100}$/.test(number)) return; const current = entry.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId); if (!current) return; entry.nodes = model.upsert(entry.nodes, { ...current, PartyNumber: number }); if (entry.edit?.person.id === person.id) { entry.edit.person = { ...entry.edit.person, PartyNumber: number }; entry.edit.baseline = JSON.stringify({ ...JSON.parse(entry.edit.baseline), PartyNumber: number }); } const savedNodes = JSON.parse(entry.baseline); const saved = savedNodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId); if (!saved || saved.PartyNumber === number || entry.saving) { updateControls(); return; } saved.PartyNumber = number; entry.saving = true; updateControls(); try { const chart = await request("save", { principal, customerId, nodes: savedNodes, revision: entry.revision }); if (active !== entry) return; entry.revision = chart.revision; entry.baseline = JSON.stringify(chart.nodes); } finally { if (active === entry) { entry.saving = false; updateControls(); } } } async function loadPersonDetails(edit) { if (!edit) return; const version = ++edit.detailsVersion; const isCurrent = () => active === entry && entry.edit === edit && edit.detailsVersion === version; entry.details.delete(edit.person.contactPartyId); showContactDetails(edit, null); if (!edit.person.contactPartyId) { edit.detailStatus.textContent = "Select a contact to load Job Title and email address."; return; } edit.detailStatus.textContent = "Loading contact details..."; for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Loading..."; try { let contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent); if (isCurrent() && !edit.person.PartyNumber && !contacts.find(item => String(item.PartyId) === edit.person.contactPartyId)?.PartyNumber) { contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent, edit.person.contactPartyId); } if (!isCurrent()) return; const contact = contacts.find(item => String(item.PartyId) === edit.person.contactPartyId); if (contact) { const current = entry.nodes.find(node => node.id === edit.person.id); const jobTitle = typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : ""; if (current && (current.name !== contact.PartyName.trim() || current.jobTitle !== jobTitle)) { entry.nodes = model.upsert(entry.nodes, { ...current, name: contact.PartyName, jobTitle }); edit.person = entry.nodes.find(node => node.id === current.id); const baseline = JSON.parse(edit.baseline); edit.baseline = JSON.stringify({ ...baseline, name: edit.person.name, jobTitle }); edit.fields.name.querySelector('option[value="__saved"]').textContent = edit.person.name; edit.nameCombo.sync(); changed(); } } showContactDetails(edit, contact); if (contact?.PartyNumber) await persistPartyNumber(edit.person, contact.PartyNumber); if (!contact) edit.detailStatus.textContent = "Contact details are unavailable. Select a contact to update this person."; } catch (error) { if (!isCurrent()) return; showContactDetails(edit, null); edit.detailStatus.textContent = error.message || "Unable to load contact details."; edit.detailRetry.hidden = false; } } function applyEditor() { if (!entry.edit) return true; const { form, fields } = entry.edit; if (!editorValue().name) { message("Select a contact in Name.", true); entry.edit.nameCombo.input.focus(); return false; } if (!form.reportValidity()) return false; try { const person = editorValue(); person.collapsed = entry.nodes.find(node => node.id === person.id)?.collapsed ?? person.collapsed; entry.nodes = model.upsert(entry.nodes, person); if (person.parentId) entry.nodes = entry.nodes.map(node => node.id === person.parentId ? { ...node, collapsed: false } : node); entry.edit.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = person.id; changed(); return true; } catch (error) { message(error.message, true); return false; } } function focusPerson(id) { canvas.querySelector('[data-person-id="' + id + '"] .oe-org-person')?.focus(); } function showNotes(person, anchor) { const card = anchor.closest(".oe-org-node"); if (!person.notes.trim() || entry.saving || active !== entry || !card?.isConnected) return; tooltip.textContent = person.notes; tooltip.hidden = false; const box = card.getBoundingClientRect(), rect = tooltip.getBoundingClientRect(); const width = document.documentElement.clientWidth, height = document.documentElement.clientHeight; tooltip.style.left = Math.max(12, Math.min(box.left, width - rect.width - 12)) + "px"; tooltip.style.top = Math.max(12, box.bottom + rect.height + 8 > height ? box.top - rect.height - 8 : box.bottom + 8) + "px"; } function render() { tooltip.hidden = true; canvas.replaceChildren(); const visible = entry.ready && entry.nodes.length; empty.hidden = !entry.ready || !!visible; sizing.hidden = !visible; count.textContent = entry.nodes.length + (entry.nodes.length === 1 ? " person" : " people"); if (!visible) return; const d3 = loadLayout(), root = d3.hierarchy(model.hierarchy(entry.nodes)); d3.tree().nodeSize([nodeWidth + 28, nodeHeight + 54]).separation(() => 1)(root); const nodes = root.descendants().filter(node => node.depth > 0), left = Math.min(...nodes.map(node => node.x)) - nodeWidth / 2 - 28; entry.width = Math.max(...nodes.map(node => node.x)) - left + nodeWidth / 2 + 28; entry.height = Math.max(...nodes.map(node => (node.depth - 1) * (nodeHeight + 54))) + nodeHeight + 56; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("oe-org-lines"); svg.setAttribute("width", entry.width); svg.setAttribute("height", entry.height); svg.setAttribute("aria-hidden", "true"); canvas.append(svg); for (const node of nodes) { const x = node.x - left - nodeWidth / 2, y = (node.depth - 1) * (nodeHeight + 54) + 28; if (node.parent.depth > 0) { const px = node.parent.x - left, py = y - 54, path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M" + px + "," + py + " V" + (py + 27) + " H" + (x + nodeWidth / 2) + " V" + y); svg.append(path); } const person = node.data, card = el("article", "oe-org-node"); card.setAttribute("data-person-id", person.id); card.setAttribute("data-selected", String(entry.selected === person.id)); card.style.left = x + "px"; card.style.top = y + "px"; const main = button("Edit " + person.name, "chart", () => guardEditor(() => editPerson(entry.nodes.find(item => item.id === person.id)))); main.className = "oe-org-person"; main.removeAttribute("title"); main.replaceChildren(el("strong", "", person.name), el("span", "", person.jobTitle || "Job Title not provided")); card.addEventListener("mouseenter", () => showNotes(person, main)); card.addEventListener("mouseleave", () => { tooltip.hidden = true; }); main.addEventListener("focus", () => showNotes(person, main)); main.addEventListener("blur", () => { tooltip.hidden = true; }); if (person.notes.trim()) main.setAttribute("aria-describedby", tooltip.id); const actions = el("div", "oe-org-node-actions"); actions.append(button("Add report to " + person.name, "plus", () => guardEditor(() => editPerson(null, person.id)))); const remove = button("Delete " + person.name, "trash", () => guardEditor(() => deletePerson(person))); remove.classList.add("oe-org-danger"); actions.append(remove); const opportunities = button("View opportunities for " + person.name, "opportunities", () => showContactOpportunities(person, opportunities)); opportunities.setAttribute("aria-haspopup", "dialog"); actions.append(opportunities); if (person.notes.trim()) { const marker = el("span", "oe-org-note-marker"); marker.append(icon("note")); marker.title = "Has notes"; actions.append(marker); } const reports = entry.nodes.filter(item => item.parentId === person.id).length; if (reports) { const toggle = button((person.collapsed ? "Expand" : "Collapse") + " reports for " + person.name, person.collapsed ? "down" : "up", () => guardEditor(() => { entry.nodes = entry.nodes.map(item => item.id === person.id ? { ...item, collapsed: !item.collapsed } : item); changed(); render(); focusPerson(person.id); })); toggle.setAttribute("aria-expanded", String(!person.collapsed)); toggle.append(el("span", "", String(reports))); actions.append(toggle); } card.append(main, actions); canvas.append(card); } if (entry.autoFit) fit(); else setZoom(entry.zoom, true); } function setZoom(value, preserveFit = false) { if (!preserveFit) entry.autoFit = false; entry.zoom = Math.max(.25, Math.min(1.5, value)); canvas.style.width = entry.width + "px"; canvas.style.height = entry.height + "px"; canvas.style.transform = "scale(" + entry.zoom + ")"; sizing.style.width = entry.width * entry.zoom + "px"; sizing.style.height = entry.height * entry.zoom + "px"; zoomValue.textContent = Math.round(entry.zoom * 100) + "%"; } function fit() { if (!entry.nodes.length) return; entry.autoFit = true; setZoom(Math.min(1, (viewport.clientWidth - 24) / entry.width, (viewport.clientHeight - 24) / entry.height), true); viewport.scrollTo({ left: 0, top: 0 }); } function foldAll(collapsed) { guardEditor(() => { entry.nodes = entry.nodes.map(person => ({ ...person, collapsed })); changed(); render(); fit(); }); } async function load() { retry.hidden = true; entry.ready = false; updateControls(); modal.setAttribute("aria-busy", "true"); empty.hidden = true; canvas.replaceChildren(el("div", "oe-org-skeleton")); sizing.hidden = false; message("Loading organization chart..."); try { if (!customerId || !principal) throw new Error("The current user and customer must be available before opening an organization chart."); const chart = await request("load", { principal, customerId }); if (active !== entry) return; entry.nodes = model.normalize(chart.nodes); entry.baseline = JSON.stringify(entry.nodes); entry.revision = chart.revision; entry.ready = true; message(chart.updatedAt ? "Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US") : "Not saved yet"); render(); fit(); } catch (error) { if (active === entry) { canvas.replaceChildren(); message(error.message || "Unable to load organization chart.", true); retry.hidden = false; } } finally { if (active === entry) { modal.setAttribute("aria-busy", "false"); updateControls(); } } } async function saveChart() { if (!entry.ready || entry.saving) return; if (!applyEditor()) return; render(); entry.saving = true; updateControls(); message("Saving locally..."); try { const chart = await request("save", { principal, customerId, nodes: entry.nodes, revision: entry.revision }); if (active !== entry) return; entry.revision = chart.revision; entry.baseline = JSON.stringify(entry.nodes); message("Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US")); notify("Organization chart saved locally."); } catch (error) { if (active === entry) { message(error.message || "Unable to save organization chart.", true); notify("Organization chart could not be saved.", true); } } finally { if (active === entry) { entry.saving = false; updateControls(); } } } let drag = null; viewport.addEventListener("pointerdown", event => { if (event.button !== 0 || event.target.closest("button, input, textarea, .oe-org-node")) return; drag = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop }; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", event => { if (drag) { viewport.scrollLeft = drag.left - event.clientX + drag.x; viewport.scrollTop = drag.top - event.clientY + drag.y; } }); viewport.addEventListener("pointerup", () => { drag = null; }); viewport.addEventListener("pointercancel", () => { drag = null; }); viewport.addEventListener("scroll", () => { tooltip.hidden = true; }); if (environment.window?.ResizeObserver) { entry.observer = new environment.window.ResizeObserver(() => { tooltip.hidden = true; if (entry.autoFit) fit(); }); entry.observer.observe(viewport); } modal.addEventListener("keydown", event => { if (entry.confirmation) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); tooltip.hidden = true; close(); } if (event.key === "Tab") { const controls = [...modal.querySelectorAll("button, input, textarea, [tabindex='0']")].filter(item => !item.disabled && !item.closest("[hidden], [inert]") && item.getClientRects().length); const first = controls[0], last = controls.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } } }); dismiss.focus(); load(); } return { createButton, open, close }; }