Files
opportunities-extension/src/features/org-chart/view.js

599 lines
45 KiB
JavaScript

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 };
}