Implemented the Organization Chart feature to create customer organizational
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
function createBackgroundApplication(overrides = {}) {
|
||||
const environment = {
|
||||
indexedDB: globalThis.indexedDB,
|
||||
fetch: globalThis.fetch,
|
||||
browser: globalThis.browser,
|
||||
chrome: globalThis.chrome,
|
||||
@@ -8,6 +9,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
|
||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||
const contracts = createBackgroundContracts();
|
||||
const spaPageClient = createSpaPageClient({
|
||||
@@ -175,6 +177,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
loadOrganizationChart: message => orgChartStorage.load(message),
|
||||
saveOrganizationChart: message => orgChartStorage.save(message),
|
||||
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
loadOrganizationChart,
|
||||
saveOrganizationChart,
|
||||
messageTypes,
|
||||
requestWeekDetails,
|
||||
requestTimeEntries,
|
||||
@@ -46,6 +48,11 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
|
||||
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
|
||||
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestLookupTaskType) {
|
||||
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
|
||||
@@ -3,6 +3,7 @@ function createContentApplication(overrides = {}) {
|
||||
document: globalThis.document,
|
||||
window: globalThis.window,
|
||||
navigator: globalThis.navigator,
|
||||
crypto: globalThis.crypto,
|
||||
get localStorage() { return globalThis.localStorage; },
|
||||
fetch: globalThis.fetch,
|
||||
browser: globalThis.browser,
|
||||
@@ -30,6 +31,27 @@ function createContentApplication(overrides = {}) {
|
||||
const authSalesCommonState = createAuthSalesCommonState();
|
||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||
const tableSorting = createTableSorting({ environment });
|
||||
const orgChartIcons = createOrgChartIcons({ environment });
|
||||
const accountContact = createFusionAccountContact({ environment,
|
||||
getToken: () => authContentSession.requestTokenRelayOnce(),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args)
|
||||
});
|
||||
const orgChart = createOrgChartView({ environment, tableSorting, model: createOrgChartModel(),
|
||||
requestQueryAccountContact: (...args) => accountContact.requestQueryAccountContact(...args),
|
||||
requestPersistContact: (...args) => accountContact.requestPersistContact(...args),
|
||||
requestOpportunitiesByContact: (...args) => accountContact.requestOpportunitiesByContact(...args),
|
||||
loadLayout: loadOrganizationLayout, icon: orgChartIcons.icon, searchableSelect, modalId: contracts.MODAL_ID,
|
||||
getPrincipal: () => currentUser.getUserId(), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
async request(operation, data) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ ...data,
|
||||
type: operation === "load" ? messageTypes.loadOrganizationChart : messageTypes.saveOrganizationChart });
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to access local organization charts.");
|
||||
return result.chart;
|
||||
}
|
||||
});
|
||||
const opportunitiesTableState = createOpportunitiesTableState();
|
||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||
const preferencesState = createPreferencesState(contracts);
|
||||
@@ -94,6 +116,7 @@ function createContentApplication(overrides = {}) {
|
||||
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
||||
closeOrganizationChart: () => orgChart.close(),
|
||||
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||
@@ -376,6 +399,7 @@ function createContentApplication(overrides = {}) {
|
||||
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
||||
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
|
||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||
@@ -779,6 +803,7 @@ function createContentApplication(overrides = {}) {
|
||||
}
|
||||
return {
|
||||
opportunityCalendar,
|
||||
orgChart,
|
||||
workloadActionForm,
|
||||
activityFilters,
|
||||
start, contracts,
|
||||
|
||||
@@ -28,6 +28,7 @@ function createContentModal({
|
||||
closeAccountPlansModal,
|
||||
closeInstallBaseModal,
|
||||
closeServiceRequestsModal,
|
||||
closeOrganizationChart,
|
||||
closeActionForm,
|
||||
closeCustomerFilterPanel,
|
||||
closeOwnerFilterPanel,
|
||||
@@ -468,6 +469,7 @@ function createContentModal({
|
||||
}
|
||||
|
||||
function closeOpportunitiesModal() {
|
||||
if (closeOrganizationChart()) return;
|
||||
resetAuthCompletion();
|
||||
closeCrmSrPopover();
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
@@ -506,6 +508,7 @@ function createContentModal({
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (closeOrganizationChart()) { event.preventDefault(); return; }
|
||||
if (closeActionForm()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -9,14 +9,14 @@ function createDebugContentLog({
|
||||
name: details.name,
|
||||
method: details.method,
|
||||
url: details.url,
|
||||
headers: details.headers,
|
||||
headers: details.headers || {},
|
||||
status: "pending",
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
httpStatus: null,
|
||||
statusText: "",
|
||||
requestHeaders: details.headers,
|
||||
requestHeaders: details.headers || {},
|
||||
requestBody: details.body || "",
|
||||
metadata: details.metadata || {},
|
||||
responseHeaders: {},
|
||||
@@ -112,7 +112,7 @@ function createDebugContentLog({
|
||||
}
|
||||
|
||||
function formatHeaderBlock(headers) {
|
||||
const entries = Object.entries(headers);
|
||||
const entries = Object.entries(headers || {});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
@@ -179,14 +179,14 @@ function createDebugContentLog({
|
||||
}
|
||||
|
||||
function formatMetadataBlock(metadata) {
|
||||
const entries = Object.entries(metadata);
|
||||
const entries = Object.entries(metadata || {});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${value || "(none)"}`)
|
||||
.map(([key, value]) => `${key}: ${value ?? "(none)"}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ function createDebugContentView({
|
||||
const { document } = environment;
|
||||
const categories = [
|
||||
["all", "All"], ["authentication", "Authentication"],
|
||||
["opportunities", "Opportunities"], ["products", "Products"],
|
||||
["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
|
||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
||||
];
|
||||
@@ -21,7 +21,8 @@ function createDebugContentView({
|
||||
|
||||
function getRequestCategory(entry) {
|
||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||
if (["requestOpportunities", "requestOpportunitiesSavedSearch"].includes(entry.name)) return "opportunities";
|
||||
if (["requestOpportunities", "requestOpportunitiesSavedSearch", "requestOpportunitiesByContact", "resolveContactForOpportunities"].includes(entry.name)) return "opportunities";
|
||||
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||
@@ -54,6 +55,9 @@ function createDebugContentView({
|
||||
return;
|
||||
}
|
||||
returnFocus = document.activeElement;
|
||||
// Escape nested stacking contexts and inert containers created by child modals.
|
||||
modal.append(panel);
|
||||
panel.inert = false;
|
||||
panel.hidden = false;
|
||||
renderDebugPanel();
|
||||
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
--debug-literal: #73429b;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: auto;
|
||||
|
||||
@@ -6,7 +6,10 @@ function createSearchableSelect({ environment }) {
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180 } = {}) {
|
||||
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180, search, searchDelay = 350 } = {}) {
|
||||
let searchTimer, searchVersion = 0, disposed = false;
|
||||
const schedule = environment.setTimeout || globalThis.setTimeout, cancel = environment.clearTimeout || globalThis.clearTimeout;
|
||||
function cancelSearch() { searchVersion++; if (searchTimer) cancel(searchTimer); searchTimer = null; input.removeAttribute("aria-busy"); }
|
||||
const box = element("div", "opportunities-extension-action-combobox");
|
||||
const input = element("input");
|
||||
input.type = "text";
|
||||
@@ -67,6 +70,7 @@ function createSearchableSelect({ environment }) {
|
||||
const options = () => Array.from(select.children).filter(item => item.value);
|
||||
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
function closeList() {
|
||||
if (search) cancelSearch();
|
||||
hideTooltip();
|
||||
list.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
@@ -115,10 +119,10 @@ function createSearchableSelect({ environment }) {
|
||||
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
|
||||
}
|
||||
}
|
||||
function render(query = "") {
|
||||
function render(query = "", statusText = "") {
|
||||
if (input.disabled) return;
|
||||
hideTooltip();
|
||||
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||
matches = statusText ? [] : options().filter(item => search || normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||
index = matches.findIndex(item => item.value === select.value);
|
||||
list.textContent = "";
|
||||
matches.forEach((item, i) => {
|
||||
@@ -137,7 +141,7 @@ function createSearchableSelect({ environment }) {
|
||||
list.append(row);
|
||||
});
|
||||
if (!matches.length) {
|
||||
const empty = element("div", "opportunities-extension-action-option", "No results");
|
||||
const empty = element("div", "opportunities-extension-action-option", statusText || "No results");
|
||||
empty.setAttribute("role", "status");
|
||||
list.append(empty);
|
||||
}
|
||||
@@ -147,7 +151,33 @@ function createSearchableSelect({ environment }) {
|
||||
highlight(false);
|
||||
}
|
||||
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||
input.addEventListener("input", () => render(input.value));
|
||||
function searchInput(event) {
|
||||
if (!search) { render(input.value); return; }
|
||||
cancelSearch(); select.replaceChildren(select.children[0]); select.value = ""; onChange?.();
|
||||
const query = input.value;
|
||||
render("", query.trim() ? "Searching..." : "Type to search.");
|
||||
if (!query.trim() || event?.isComposing) return;
|
||||
const version = searchVersion;
|
||||
const isCurrent = () => !disposed && version === searchVersion && input.isConnected && document.activeElement === input;
|
||||
input.setAttribute("aria-busy", "true");
|
||||
searchTimer = schedule(async () => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const results = await search(query, isCurrent);
|
||||
if (!isCurrent()) return;
|
||||
const placeholder = select.children[0]; select.replaceChildren(placeholder);
|
||||
for (const result of results) {
|
||||
const option = element("option", "", result.label); option.value = result.value;
|
||||
if (result.description) option.setAttribute("data-description", result.description);
|
||||
select.append(option);
|
||||
}
|
||||
select.value = ""; render();
|
||||
} catch (error) { if (isCurrent()) render("", (error.message || "Unable to search contacts.") + " Type to retry."); }
|
||||
finally { if (isCurrent()) input.removeAttribute("aria-busy"); }
|
||||
}, searchDelay);
|
||||
}
|
||||
input.addEventListener("input", searchInput);
|
||||
if (search) input.addEventListener("compositionend", searchInput);
|
||||
input.addEventListener("blur", sync);
|
||||
if (tooltip) {
|
||||
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
|
||||
@@ -169,7 +199,7 @@ function createSearchableSelect({ environment }) {
|
||||
} else if (event.key === "Tab") sync();
|
||||
});
|
||||
sync();
|
||||
return { input, sync };
|
||||
return { input, sync, destroy() { disposed = true; closeList(); } };
|
||||
}
|
||||
return searchableSelect;
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
/* Keep layout rules on the outer table, never on expanded product tables. */
|
||||
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
|
||||
width: 100%;
|
||||
min-width: 1120px;
|
||||
min-width: 1168px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
|
||||
min-width: 84px;
|
||||
min-width: 114px;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
|
||||
|
||||
@@ -7,6 +7,7 @@ function createOpportunitiesTable({
|
||||
createCloudConsumptionButton,
|
||||
createInstallBaseButton,
|
||||
createServiceRequestsButton,
|
||||
createOrganizationChartButton,
|
||||
createCopyOpportunityButton,
|
||||
createDetailLink,
|
||||
createProductsCountButton,
|
||||
@@ -122,7 +123,7 @@ function createOpportunitiesTable({
|
||||
if (column.key === "name" || column.key === "customer") {
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "opportunities-extension-cell-actions";
|
||||
for (let action = 0; action < (column.key === "name" ? 4 : 3); action++) {
|
||||
for (let action = 0; action < 4; action++) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-badge";
|
||||
actions.append(badge);
|
||||
@@ -198,7 +199,7 @@ function createOpportunitiesTable({
|
||||
);
|
||||
const customerActions = document.createElement("span");
|
||||
customerActions.className = "opportunities-extension-cell-actions";
|
||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item));
|
||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item), createOrganizationChartButton(item));
|
||||
customerCellContent.append(customerLink, customerActions);
|
||||
cell.append(customerCellContent);
|
||||
} else {
|
||||
|
||||
3
src/features/org-chart/calling-codes.js
Normal file
3
src/features/org-chart/calling-codes.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Calling codes: Google libphonenumber, Apache-2.0. Retrieved 2026-10-26.
|
||||
// https://github.com/google/libphonenumber/blob/master/resources/PhoneNumberMetadata.xml
|
||||
const orgChartCallingCodes = [["AC","247"],["AD","376"],["AE","971"],["AF","93"],["AG","1"],["AI","1"],["AL","355"],["AM","374"],["AO","244"],["AR","54"],["AS","1"],["AT","43"],["AU","61"],["AW","297"],["AX","358"],["AZ","994"],["BA","387"],["BB","1"],["BD","880"],["BE","32"],["BF","226"],["BG","359"],["BH","973"],["BI","257"],["BJ","229"],["BL","590"],["BM","1"],["BN","673"],["BO","591"],["BQ","599"],["BR","55"],["BS","1"],["BT","975"],["BW","267"],["BY","375"],["BZ","501"],["CA","1"],["CC","61"],["CD","243"],["CF","236"],["CG","242"],["CH","41"],["CI","225"],["CK","682"],["CL","56"],["CM","237"],["CN","86"],["CO","57"],["CR","506"],["CU","53"],["CV","238"],["CW","599"],["CX","61"],["CY","357"],["CZ","420"],["DE","49"],["DJ","253"],["DK","45"],["DM","1"],["DO","1"],["DZ","213"],["EC","593"],["EE","372"],["EG","20"],["EH","212"],["ER","291"],["ES","34"],["ET","251"],["FI","358"],["FJ","679"],["FK","500"],["FM","691"],["FO","298"],["FR","33"],["GA","241"],["GB","44"],["GD","1"],["GE","995"],["GF","594"],["GG","44"],["GH","233"],["GI","350"],["GL","299"],["GM","220"],["GN","224"],["GP","590"],["GQ","240"],["GR","30"],["GT","502"],["GU","1"],["GW","245"],["GY","592"],["HK","852"],["HN","504"],["HR","385"],["HT","509"],["HU","36"],["ID","62"],["IE","353"],["IL","972"],["IM","44"],["IN","91"],["IO","246"],["IQ","964"],["IR","98"],["IS","354"],["IT","39"],["JE","44"],["JM","1"],["JO","962"],["JP","81"],["KE","254"],["KG","996"],["KH","855"],["KI","686"],["KM","269"],["KN","1"],["KP","850"],["KR","82"],["KW","965"],["KY","1"],["KZ","7"],["LA","856"],["LB","961"],["LC","1"],["LI","423"],["LK","94"],["LR","231"],["LS","266"],["LT","370"],["LU","352"],["LV","371"],["LY","218"],["MA","212"],["MC","377"],["MD","373"],["ME","382"],["MF","590"],["MG","261"],["MH","692"],["MK","389"],["ML","223"],["MM","95"],["MN","976"],["MO","853"],["MP","1"],["MQ","596"],["MR","222"],["MS","1"],["MT","356"],["MU","230"],["MV","960"],["MW","265"],["MX","52"],["MY","60"],["MZ","258"],["NA","264"],["NC","687"],["NE","227"],["NF","672"],["NG","234"],["NI","505"],["NL","31"],["NO","47"],["NP","977"],["NR","674"],["NU","683"],["NZ","64"],["OM","968"],["PA","507"],["PE","51"],["PF","689"],["PG","675"],["PH","63"],["PK","92"],["PL","48"],["PM","508"],["PR","1"],["PS","970"],["PT","351"],["PW","680"],["PY","595"],["QA","974"],["RE","262"],["RO","40"],["RS","381"],["RU","7"],["RW","250"],["SA","966"],["SB","677"],["SC","248"],["SD","249"],["SE","46"],["SG","65"],["SH","290"],["SI","386"],["SJ","47"],["SK","421"],["SL","232"],["SM","378"],["SN","221"],["SO","252"],["SR","597"],["SS","211"],["ST","239"],["SV","503"],["SX","1"],["SY","963"],["SZ","268"],["TA","290"],["TC","1"],["TD","235"],["TG","228"],["TH","66"],["TJ","992"],["TK","690"],["TL","670"],["TM","993"],["TN","216"],["TO","676"],["TR","90"],["TT","1"],["TV","688"],["TW","886"],["TZ","255"],["UA","380"],["UG","256"],["US","1"],["UY","598"],["UZ","998"],["VA","39"],["VC","1"],["VE","58"],["VG","1"],["VI","1"],["VN","84"],["VU","678"],["WF","681"],["WS","685"],["XK","383"],["YE","967"],["YT","262"],["ZA","27"],["ZM","260"],["ZW","263"]];
|
||||
28
src/features/org-chart/icons.js
Normal file
28
src/features/org-chart/icons.js
Normal file
@@ -0,0 +1,28 @@
|
||||
function createOrgChartIcons({ environment }) {
|
||||
// Lucide geometry; license included in src/vendor/lucide/LICENSE.
|
||||
const paths = {
|
||||
opportunities: ["M3 7h18v14H3Z", "M8 7V3h8v4", "M3 12h18", "M10 12v3h4v-3"],
|
||||
contact: ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0", "M20 8v6", "M17 11h6"],
|
||||
chart: ["M12 8v4", "M12 12H5v4", "M12 12h7v4"],
|
||||
plus: ["M12 5v14", "M5 12h14"],
|
||||
close: ["m6 6 12 12", "M18 6 6 18"], check: ["m9 12 2 2 4-4"],
|
||||
down: ["m6 9 6 6 6-6"], up: ["m18 15-6-6-6 6"],
|
||||
expand: ["m7 10 5-5 5 5", "m7 14 5 5 5-5"], collapse: ["m7 4 5 5 5-5", "m7 20 5-5 5 5"],
|
||||
minus: ["M5 12h14"], fit: ["M8 3H5a2 2 0 0 0-2 2v3", "M16 3h3a2 2 0 0 1 2 2v3", "M21 16v3a2 2 0 0 1-2 2h-3", "M8 21H5a2 2 0 0 1-2-2v-3"],
|
||||
save: ["M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12l4 4v12a2 2 0 0 1-2 2", "M7 21v-8h10v8", "M7 3v6h10V3"],
|
||||
trash: ["M3 6h18", "M19 6v14H5V6", "M10 11v6", "M14 11v6", "M9 6V3h6v3"],
|
||||
note: ["M12 3H5v18h14V10Z", "M12 3v7h7", "M8 15h8", "M8 18h5"]
|
||||
};
|
||||
function icon(name) {
|
||||
const svg = environment.document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.8", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false" })) svg.setAttribute(key, value);
|
||||
for (const d of paths[name] || paths.chart) { const path = environment.document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", d); svg.append(path); }
|
||||
if (name === "chart") for (const [x, y] of [[8, 2], [1, 16], [15, 16]]) {
|
||||
const rect = environment.document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
for (const [key, value] of Object.entries({ x, y, width: 8, height: 6, rx: 1 })) rect.setAttribute(key, value);
|
||||
svg.append(rect);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
return { icon };
|
||||
}
|
||||
63
src/features/org-chart/model.js
Normal file
63
src/features/org-chart/model.js
Normal file
@@ -0,0 +1,63 @@
|
||||
function createOrgChartModel() {
|
||||
function normalize(nodes) {
|
||||
if (!Array.isArray(nodes) || nodes.length > 500) throw new Error("An organization chart supports up to 500 people.");
|
||||
const ids = new Set();
|
||||
const result = nodes.map(node => {
|
||||
if (!node || typeof node.id !== "string" || !/^[\w-]{1,80}$/.test(node.id) || ids.has(node.id)) throw new Error("Invalid or duplicate person ID.");
|
||||
ids.add(node.id);
|
||||
const item = { id: node.id, parentId: node.parentId == null ? null : node.parentId, collapsed: node.collapsed === true };
|
||||
const jobTitle = node.jobTitle ?? node.role ?? "";
|
||||
if (typeof jobTitle !== "string" || jobTitle.length > 160) throw new Error("Invalid Job Title.");
|
||||
item.jobTitle = jobTitle.trim();
|
||||
if (node.PartyNumber != null && node.PartyNumber !== "") {
|
||||
if (!/^[A-Za-z0-9]{1,100}$/.test(String(node.PartyNumber))) throw new Error("Invalid contact PartyNumber.");
|
||||
item.PartyNumber = String(node.PartyNumber);
|
||||
}
|
||||
if (node.contactPartyId != null) {
|
||||
if (typeof node.contactPartyId !== "string" || !/^[\w-]{1,100}$/.test(node.contactPartyId)) throw new Error("Invalid contact ID.");
|
||||
item.contactPartyId = node.contactPartyId;
|
||||
}
|
||||
for (const [key, max] of [["name", 160], ["notes", 4000]]) {
|
||||
if (typeof node[key] !== "string" || node[key].length > max || (key === "name" && !node[key].trim())) throw new Error(`Enter a valid ${key} (up to ${max} characters).`);
|
||||
item[key] = key === "notes" ? node[key] : node[key].trim();
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const byId = new Map(result.map(node => [node.id, node]));
|
||||
for (const node of result) {
|
||||
const seen = new Set([node.id]);
|
||||
let parent = node.parentId;
|
||||
while (parent !== null) {
|
||||
if (!byId.has(parent) || seen.has(parent)) throw new Error("The reporting structure contains a missing manager or a cycle.");
|
||||
seen.add(parent);
|
||||
if (seen.size > 60) throw new Error("The reporting structure supports up to 60 levels.");
|
||||
parent = byId.get(parent).parentId;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function descendants(nodes, id) {
|
||||
const found = new Set([id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const node of nodes) if (found.has(node.parentId) && !found.has(node.id)) { found.add(node.id); changed = true; }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function upsert(nodes, person) {
|
||||
return normalize(nodes.some(node => node.id === person.id) ? nodes.map(node => node.id === person.id ? person : node) : [...nodes, person]);
|
||||
}
|
||||
function remove(nodes, id) {
|
||||
const person = nodes.find(node => node.id === id);
|
||||
if (!person) return nodes;
|
||||
return normalize(nodes.filter(node => node.id !== id).map(node => node.parentId === id ? { ...node, parentId: person.parentId } : node));
|
||||
}
|
||||
function hierarchy(nodes) {
|
||||
const root = { id: "", children: [] }, byId = new Map([[null, root]]);
|
||||
nodes.forEach(node => byId.set(node.id, { ...node, children: [] }));
|
||||
nodes.forEach(node => { if (!byId.get(node.parentId).collapsed) byId.get(node.parentId).children.push(byId.get(node.id)); });
|
||||
return root;
|
||||
}
|
||||
return { normalize, descendants, upsert, remove, hierarchy };
|
||||
}
|
||||
68
src/features/org-chart/storage.js
Normal file
68
src/features/org-chart/storage.js
Normal file
@@ -0,0 +1,68 @@
|
||||
function createOrgChartStorage({ environment, model }) {
|
||||
let opening = null;
|
||||
function database() {
|
||||
if (opening) return opening;
|
||||
opening = new Promise((resolve, reject) => {
|
||||
if (!environment.indexedDB) { reject(new Error("IndexedDB is unavailable. The organization chart cannot be saved locally.")); return; }
|
||||
const request = environment.indexedDB.open("opportunities-extension-organizations", 3);
|
||||
let blocked = false;
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains("charts")) request.result.createObjectStore("charts", { keyPath: "key" });
|
||||
else {
|
||||
const cursor = request.transaction.objectStore("charts").openCursor();
|
||||
cursor.onsuccess = () => {
|
||||
const current = cursor.result;
|
||||
if (!current) return;
|
||||
const record = current.value;
|
||||
if (Array.isArray(record.nodes)) {
|
||||
record.nodes = record.nodes.map(node => {
|
||||
const clean = { ...node, jobTitle: node.jobTitle ?? node.role ?? "" }; delete clean.role; delete clean.emailAddress; return clean;
|
||||
});
|
||||
current.update(record);
|
||||
}
|
||||
current.continue();
|
||||
};
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(new Error("Unable to open local organization charts."));
|
||||
request.onblocked = () => { blocked = true; reject(new Error("Local storage is blocked by another window. Close it and retry.")); };
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
if (blocked) { db.close(); return; }
|
||||
db.onversionchange = () => { db.close(); opening = null; };
|
||||
resolve(db);
|
||||
};
|
||||
}).catch(error => { opening = null; throw error; });
|
||||
return opening;
|
||||
}
|
||||
function identity({ principal, customerId }) {
|
||||
if (typeof principal !== "string" || !principal.trim() || principal.length > 254 || typeof customerId !== "string" || !/^[\w-]{1,100}$/.test(customerId)) throw new Error("A current user and customer ID are required.");
|
||||
return JSON.stringify([principal.trim().toLowerCase(), customerId]);
|
||||
}
|
||||
async function transact(message, write) {
|
||||
const key = identity(message);
|
||||
const nodes = write ? model.normalize(message.nodes) : null;
|
||||
if (write && (!Number.isSafeInteger(message.revision) || message.revision < 0)) throw new Error("Invalid organization chart revision.");
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction("charts", write ? "readwrite" : "readonly");
|
||||
const store = transaction.objectStore("charts");
|
||||
const request = store.get(key);
|
||||
let result, failure;
|
||||
request.onsuccess = () => {
|
||||
try {
|
||||
const existing = request.result;
|
||||
if (existing && existing.schemaVersion !== 1) throw new Error("Unsupported organization chart version.");
|
||||
if (write) {
|
||||
if ((existing?.revision || 0) !== message.revision) throw new Error("This chart changed in another window. Reopen it before saving your changes.");
|
||||
result = { key, schemaVersion: 1, revision: message.revision + 1, nodes, updatedAt: new Date().toISOString() };
|
||||
store.put(result);
|
||||
} else result = existing ? { ...existing, nodes: model.normalize(existing.nodes) } : { schemaVersion: 1, revision: 0, nodes: [], updatedAt: null };
|
||||
} catch (error) { failure = error; transaction.abort(); }
|
||||
};
|
||||
transaction.oncomplete = () => resolve({ ok: true, chart: result });
|
||||
transaction.onabort = transaction.onerror = () => reject(failure || new Error("Unable to save or read the organization chart. Check available browser storage and retry."));
|
||||
});
|
||||
}
|
||||
return { load: message => transact(message, false), save: message => transact(message, true) };
|
||||
}
|
||||
104
src/features/org-chart/styles.css
Normal file
104
src/features/org-chart/styles.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.oe-org-modal { grid-template-columns: minmax(0, 1fr); }
|
||||
.oe-org-dialog { --org-bg: #fff; --org-canvas: #faf9f8; --org-border: #dedbd7; --org-muted: #625d58; --org-accent: #00758f; --org-selected: #e3f1f4; width: min(1500px, 100%); height: min(880px, calc(100dvh - 40px)); max-height: calc(100dvh - 40px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; color: #312d2a; background: var(--org-bg); }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog { --org-bg: #292725; --org-canvas: #211f1d; --org-border: #625d58; --org-muted: #c9c3bd; --org-accent: #80d6e8; --org-selected: #203f46; color: #f5f4f2; }
|
||||
.oe-org-modal [hidden] { display: none !important; }
|
||||
.oe-org-dialog * { box-sizing: border-box; letter-spacing: 0; }
|
||||
.oe-org-dialog > header, .oe-org-toolbar, .oe-org-footer { flex: none; }
|
||||
.oe-org-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||
.oe-org-heading p { margin: 5px 0 0; font-size: 12px; color: var(--org-muted); }
|
||||
.oe-org-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--org-border); }
|
||||
.oe-org-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.oe-org-count { color: var(--org-muted); font-variant-numeric: tabular-nums; }
|
||||
.oe-org-zoom { width: 44px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.oe-org-modal .oe-org-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 34px; min-height: 34px; padding: 6px 9px; border: 1px solid var(--org-border, #8f8983); border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||
.oe-org-modal svg { width: 17px; height: 17px; flex: none; }
|
||||
.oe-org-dialog button:hover:not(:disabled) { background: var(--org-selected); }
|
||||
.oe-org-dialog :focus-visible { outline: 2px solid var(--org-accent); outline-offset: 2px; }
|
||||
.oe-org-modal .oe-org-primary { background: #006879; border-color: #006879; color: #fff; }
|
||||
.oe-org-dialog .oe-org-primary:hover:not(:disabled) { background: #005563; }
|
||||
.oe-org-dialog :disabled { opacity: .5; cursor: not-allowed; }
|
||||
.oe-org-workspace { min-height: 0; min-width: 0; flex: 1; display: flex; overflow: hidden; }
|
||||
.oe-org-viewport { position: relative; min-width: 0; min-height: 0; flex: 1; overflow: auto; background: var(--org-canvas); overscroll-behavior: contain; cursor: grab; }
|
||||
.oe-org-sizing { position: relative; margin-inline: auto; }
|
||||
.oe-org-canvas { position: relative; transform-origin: top left; }
|
||||
.oe-org-canvas .oe-org-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||
.oe-org-lines path { stroke: var(--org-border); stroke-width: 2; fill: none; }
|
||||
.oe-org-node { position: absolute; width: 236px; height: 146px; border: 1px solid var(--org-border); border-top: 3px solid var(--org-accent); border-radius: 4px; background: var(--org-bg); display: flex; flex-direction: column; cursor: default; }
|
||||
.oe-org-node[data-selected="true"] { outline: 2px solid var(--org-accent); outline-offset: 2px; }
|
||||
.oe-org-person { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 6px; text-align: left; padding: 9px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; border-radius: 0; width: 100%; }
|
||||
.oe-org-person strong, .oe-org-person > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; line-height: 18px; }
|
||||
.oe-org-person > span { color: var(--org-muted); font-size: 12px; }
|
||||
.oe-org-node-actions { flex: none; display: flex; align-items: center; gap: 4px; padding: 4px 8px; height: 38px; border-top: 1px solid var(--org-border); }
|
||||
.oe-org-node-actions .oe-org-button { min-height: 28px; height: 28px; min-width: 28px; padding: 3px; border: 0; }
|
||||
.oe-org-node-actions [aria-expanded] { margin-left: auto; font-size: 11px; }
|
||||
.oe-org-note-marker { display: inline-flex; color: var(--org-muted); margin-left: auto; }
|
||||
.oe-org-editor { flex: 0 0 310px; width: 310px; padding: 16px; border-left: 1px solid var(--org-border); overflow-y: auto; background: var(--org-bg); }
|
||||
.oe-org-editor-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 16px; }
|
||||
.oe-org-editor h3 { margin: 0; font-size: 15px; }
|
||||
.oe-org-form { display: grid; gap: 16px; }
|
||||
.oe-org-field { display: grid; gap: 6px; font-size: 12px; font-weight: 600; min-width: 0; }
|
||||
.oe-org-dialog input, .oe-org-dialog textarea { min-width: 0; width: 100%; font: inherit; font-weight: 400; line-height: 20px; color: inherit; background: var(--org-bg); border: 1px solid var(--org-muted); border-radius: 3px; padding: 7px 8px; }
|
||||
.oe-org-dialog textarea { resize: vertical; min-height: 100px; max-height: 300px; }
|
||||
.oe-org-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.oe-contact-name { position: relative; }
|
||||
.oe-org-dialog .oe-contact-name > input { padding-right: 42px; }
|
||||
.oe-org-modal .oe-contact-name > .oe-org-button { position: absolute; right: 2px; top: 2px; min-height: 30px; height: 30px; border: 0; }
|
||||
.oe-org-dialog.oe-contact-dialog { width: min(680px, 100%); height: auto; padding: 20px; gap: 20px; overflow-y: auto; }
|
||||
.oe-contact-dialog > header { margin: 0; }
|
||||
.oe-org-dialog.oe-contact-opportunities { width: min(1200px, 100%); height: auto; padding: 20px; gap: 16px; }
|
||||
.oe-contact-opportunities > header { margin: 0; }
|
||||
.oe-contact-opportunities-table { overflow: auto; min-height: 0; }
|
||||
.oe-contact-opportunities table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.oe-contact-opportunities th, .oe-contact-opportunities td { padding: 12px; text-align: left; border-bottom: 1px solid var(--org-border); }
|
||||
.oe-contact-opportunities th { background: var(--org-canvas); white-space: nowrap; }
|
||||
.oe-contact-opportunities .opportunities-extension-sort-button { white-space: nowrap; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-start; text-align: left; }
|
||||
.oe-contact-opportunities .opportunities-extension-sort-button::after { right: 0; }
|
||||
.oe-contact-opportunities th:nth-child(5), .oe-contact-opportunities td:nth-child(5) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button { padding: 4px 0 4px 18px; justify-content: flex-end; text-align: right; }
|
||||
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button::after { left: 0; right: auto; }
|
||||
.oe-contact-opportunities td:nth-child(5) .oe-contact-skeleton { margin-left: auto; }
|
||||
.oe-contact-opportunities .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
||||
.oe-contact-opportunities table { min-width: 900px; }
|
||||
.oe-contact-opportunities a { color: var(--org-accent); }
|
||||
.oe-contact-skeleton { display: block; height: 14px; min-width: 24px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||
.oe-contact-skeleton-total { width: 110px; }
|
||||
.oe-contact-opportunities table[aria-busy="true"] { table-layout: fixed; }
|
||||
.oe-contact-opportunities table[aria-busy="true"] th:nth-child(3) { width: 28%; }
|
||||
@media (prefers-reduced-motion: reduce) { .oe-contact-skeleton { animation: none; } }
|
||||
.oe-contact-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-contact-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||
.oe-contact-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.oe-contact-phone { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; border: 1px solid var(--org-border); border-radius: 4px; padding: 12px; min-width: 0; margin: 0; }
|
||||
.oe-contact-form legend { font-weight: 600; padding: 0 4px; }
|
||||
.oe-contact-primary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 20px; border: 0; padding: 0; margin: 0; }
|
||||
.oe-contact-primary label { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.oe-contact-primary input { width: auto; accent-color: var(--org-accent); }
|
||||
@media (max-width: 520px) { .oe-contact-form, .oe-contact-phone { grid-template-columns: minmax(0, 1fr); } }
|
||||
.oe-org-dialog .oe-org-danger, .oe-org-error { color: #b3261e; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog .oe-org-danger, .opportunities-extension-modal[data-theme="dark"] .oe-org-error { color: #ffaaa4; }
|
||||
.oe-org-footer { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--org-border); }
|
||||
.oe-org-status { margin: 0 auto 0 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.oe-org-footer .oe-org-button { flex: none; }
|
||||
.oe-org-empty { display: grid; place-content: center; justify-items: center; height: 100%; min-height: 180px; gap: 12px; }
|
||||
.oe-org-empty > svg { width: 40px; height: 40px; color: var(--org-accent); }
|
||||
.oe-org-empty h3 { margin: 0; font-size: 16px; }
|
||||
.oe-org-tooltip { position: fixed; z-index: 20; max-width: min(360px, calc(100vw - 24px)); max-height: min(260px, 45dvh); overflow-y: auto; padding: 12px; background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 4px; box-shadow: 0 3px 12px #0002; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 18px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-org-tooltip { background: #35322f; color: #f5f4f2; }
|
||||
.oe-org-tooltip { pointer-events: none; }
|
||||
.oe-org-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; z-index: 30; padding: 16px; }
|
||||
.oe-org-confirm-panel { width: min(430px, 100%); background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 6px; padding: 24px; overflow-wrap: anywhere; }
|
||||
.oe-org-confirm-panel h3 { margin: 0 0 12px; font-size: 18px; }
|
||||
.oe-org-confirm-panel p { font-size: 13px; line-height: 20px; margin: 0 0 24px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel { background: #292725; color: #f5f4f2; }
|
||||
.oe-org-skeleton { width: 236px; height: 146px; margin: 28px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||
@media (prefers-reduced-motion: reduce) { .oe-org-skeleton { animation: none; } }
|
||||
@media (max-width: 760px) {
|
||||
.oe-org-modal { padding: 8px; }
|
||||
.oe-org-dialog { height: calc(100dvh - 16px); max-height: calc(100dvh - 16px); }
|
||||
.oe-org-workspace { flex-direction: column; }
|
||||
.oe-org-editor { flex: 0 1 55%; width: 100%; border-left: 0; border-top: 1px solid var(--org-border); }
|
||||
.oe-org-viewport { min-height: 140px; }
|
||||
.oe-org-toolbar { padding: 8px; gap: 6px; }
|
||||
.oe-org-tools { gap: 2px; }
|
||||
.oe-org-footer { padding: 8px; }
|
||||
}
|
||||
598
src/features/org-chart/view.js
Normal file
598
src/features/org-chart/view.js
Normal file
@@ -0,0 +1,598 @@
|
||||
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 };
|
||||
}
|
||||
105
src/services/fusion/account-contact.js
Normal file
105
src/services/fusion/account-contact.js
Normal file
@@ -0,0 +1,105 @@
|
||||
function createFusionAccountContact({ environment, getToken, logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug }) {
|
||||
async function requestQueryAccountContact(accountId, keywords, isActive = () => true, contactPartyId = null) {
|
||||
if (!/^\d+$/.test(String(accountId)) || !Number.isSafeInteger(Number(accountId))) throw new Error("The customer's PartyId is unavailable.");
|
||||
if (contactPartyId != null && !/^\d+$/.test(String(contactPartyId))) throw new Error("The contact's PartyId is unavailable.");
|
||||
if ((!String(keywords || "").trim() && contactPartyId == null) || !isActive()) return [];
|
||||
const token = await getToken();
|
||||
if (!isActive()) return [];
|
||||
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
|
||||
const fields = ["PartyName", "PrimaryEmail.EmailAddress", "JobTitle", "PrimaryAccount.PartyName", "PartyId", "PartyNumber"];
|
||||
const payload = { aggregationResults: false, skipInValidFields: true, skipHiddenFromUIFields: true,
|
||||
language: "en", onlyData: true, keywords, entity: "Contact", fields, keywordsFields: fields,
|
||||
q: { op: "$and", criteria: [{ op: "$or", innerQuery: true, criteria: [{ attribute: "PrimaryAccount.PartyId", op: "$eq", value: Number(accountId) }] }], innerQuery: true },
|
||||
sort: [{ attribute: "_score", direction: "descending" }], limit: 15, offset: 0 };
|
||||
if (contactPartyId != null) {
|
||||
payload.keywords = "";
|
||||
payload.q.criteria.push({ attribute: "PartyId", op: "$eq", value: String(contactPartyId) });
|
||||
}
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Preference: "transient", Origin: "https://eeho.fa.us2.oraclecloud.com" };
|
||||
const entry = logRequestStart({ name: "requestQueryAccountContact", method: "POST", url, headers, body: JSON.stringify(payload, null, 2) });
|
||||
try {
|
||||
const response = await environment.fetch.call(environment.window, url, { method: "POST", credentials: "include", headers, body: JSON.stringify(payload) });
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (!response.ok) throw new Error(`requestQueryAccountContact failed with status ${response.status}.`);
|
||||
const data = await response.json();
|
||||
if (!data || !Array.isArray(data.items)) throw new Error("requestQueryAccountContact returned an invalid response.");
|
||||
if (!isActive()) return [];
|
||||
const contacts = new Map();
|
||||
for (const item of data.items) if (item?.PartyId != null && typeof item.PartyName === "string" && item.PartyName.trim()) {
|
||||
contacts.set(String(item.PartyId), { ...item, PartyId: String(item.PartyId) });
|
||||
}
|
||||
return [...contacts.values()];
|
||||
} catch (error) { logRequestFailure(entry, error.message); throw error; }
|
||||
}
|
||||
async function requestPersistContact(account, values) {
|
||||
if (!/^\d+$/.test(String(account?.PartyId)) || !String(account?.PartyNumber || "").trim()) throw new Error("The customer's PartyId or PartyNumber is unavailable.");
|
||||
const phone = (number, country) => {
|
||||
if (!number && !country) return "";
|
||||
if (!number || !country) throw new Error("Select a country code and enter a phone number for each phone provided.");
|
||||
const digits = number.replace(/[\s().-]/g, "");
|
||||
if (!/^\d+$/.test(digits)) throw new Error("Enter the phone number using digits only, without the country code.");
|
||||
return country + "-" + digits;
|
||||
};
|
||||
const payload = {
|
||||
FirstName: values.firstName.trim(), LastName: values.lastName.trim(),
|
||||
AccountPartyId: String(account.PartyId), AccountPartyNumber: String(account.PartyNumber),
|
||||
JobTitle: values.jobTitle.trim(), Type: "ZCA_CONTACT",
|
||||
EmailAddress: values.email.trim(),
|
||||
RawWorkPhoneNumber: phone(values.workPhone.trim(), values.workCountry), WorkPhoneCountryCode: values.workCountry,
|
||||
WorkPhoneExtension: values.workExtension.trim(), MobileCountryCode: values.mobileCountry,
|
||||
RawMobileNumber: phone(values.mobilePhone.trim(), values.mobileCountry)
|
||||
};
|
||||
if (!payload.FirstName || !payload.LastName || !payload.EmailAddress) throw new Error("First Name, Last Name and Email Address are required.");
|
||||
for (const key of Object.keys(payload)) if (payload[key] === "") delete payload[key];
|
||||
for (const [choice, allow, deny] of [
|
||||
[values.emailConsent, "DoContactEmailFlag", "DoNotContactEmailFlag"],
|
||||
[values.workConsent, "DoCallWorkPhoneFlag", "DoNotCallWorkPhoneFlag"],
|
||||
[values.mobileConsent, "DoCallMobilePhoneFlag", "DoNotCallMobileFlag"]
|
||||
]) {
|
||||
if (choice === "allow" || choice === "deny") {
|
||||
payload[allow] = choice === "allow";
|
||||
payload[deny] = choice === "deny";
|
||||
}
|
||||
}
|
||||
const body = JSON.stringify(payload);
|
||||
const token = await getToken();
|
||||
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/rest/rv:e75ce5f9-a69f-4c6a-8591-6b06a538829e/en/11.13.18.05:9/contacts?onlyData=true";
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Origin: "https://eeho.fa.us2.oraclecloud.com" };
|
||||
const entry = logRequestStart({ name: "requestPersistContact", method: "POST", url, headers, body: JSON.stringify(payload, null, 2) });
|
||||
try {
|
||||
const response = await environment.fetch.call(environment.window, url, { method: "POST", credentials: "include", headers, body });
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (!response.ok) throw new Error(`requestPersistContact failed with status ${response.status}.`);
|
||||
const text = await response.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return null; }
|
||||
} catch (error) { logRequestFailure(entry, error.message); throw error; }
|
||||
}
|
||||
async function requestOpportunitiesByContact(partyNumber, offset = 0, context = {}) {
|
||||
const baseUrl = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/rest/rv:e75ce5f9-a69f-4c6a-8591-6b06a538829e/en/11.13.18.05:9/opportunities";
|
||||
let entry;
|
||||
try {
|
||||
if (!String(partyNumber || "").trim() && context.resolvePartyNumber) partyNumber = await context.resolvePartyNumber();
|
||||
partyNumber = String(partyNumber || "").trim();
|
||||
if (!/^[A-Za-z0-9]{1,100}$/.test(partyNumber)) throw new Error("The person's PartyNumber is missing or invalid. The opportunities HTTP request was not sent.");
|
||||
if (context.isActive && !context.isActive()) throw new Error("Opportunities lookup cancelled because its dialog was closed. HTTP request was not sent.");
|
||||
const token = await getToken();
|
||||
const fields = "SalesStageId,KeyContactId,EffectiveDate,WinProb,CurrencyCode,Revenue,SalesStage,Name,ManageRestrictedProfileDataFlag,OptyId,OptyNumber,ManageRevenueDataFlag,StatusCode";
|
||||
const query = encodeURIComponent("(OpportunityContact.ContactPartyNumber='" + partyNumber + "')").replace(/[()']/g, character => "%" + character.charCodeAt(0).toString(16).toUpperCase());
|
||||
const url = baseUrl + "?onlyData=true&q=" + query + "&totalResults=true&fields=" + encodeURIComponent(fields) + "&orderBy=LastUpdateDate%3Adesc&limit=5&offset=" + offset;
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Preference: "transient", Origin: "https://eeho.fa.us2.oraclecloud.com" };
|
||||
entry = logRequestStart({ name: "requestOpportunitiesByContact", method: "GET", url, headers,
|
||||
metadata: { contactPartyId: context.contactPartyId || "", contactName: context.contactName || "", partyNumber, offset, httpRequestSent: true } });
|
||||
const response = await environment.fetch.call(environment.window, url, { method: "GET", credentials: "include", headers });
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (!response.ok) throw new Error(`requestOpportunitiesByContact failed with status ${response.status}.`);
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesByContact returned an invalid response.");
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (!entry) entry = logRequestStart({ name: "resolveContactForOpportunities", method: "LOCAL", url: "(HTTP request not sent)",
|
||||
metadata: { contactPartyId: context.contactPartyId || "", contactName: context.contactName || "", offset, httpRequestSent: false, targetRequest: "requestOpportunitiesByContact" } });
|
||||
logRequestFailure(entry, error.message); throw error;
|
||||
}
|
||||
}
|
||||
return { requestQueryAccountContact, requestPersistContact, requestOpportunitiesByContact };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
loadOrganizationChart: "opportunitiesExtension.loadOrganizationChart",
|
||||
saveOrganizationChart: "opportunitiesExtension.saveOrganizationChart",
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
|
||||
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
|
||||
|
||||
13
src/vendor/d3-hierarchy/LICENSE
vendored
Normal file
13
src/vendor/d3-hierarchy/LICENSE
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
Copyright 2010-2021 Mike Bostock
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
11
src/vendor/d3-hierarchy/NOTICE.md
vendored
Normal file
11
src/vendor/d3-hierarchy/NOTICE.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# D3 Hierarchy
|
||||
|
||||
Version: 3.1.2. Unmodified browser bundle, distributed under ISC (`LICENSE`).
|
||||
|
||||
- Source: https://github.com/d3/d3-hierarchy/tree/v3.1.2
|
||||
- Bundle: https://cdn.jsdelivr.net/npm/d3-hierarchy@3.1.2/dist/d3-hierarchy.min.js
|
||||
- SHA-256: `a8771380454be89ec5ffe9a6396ba7c247081e348ae740dc9cb9629abd4c0e43`
|
||||
|
||||
The build wraps the UMD bundle in a lazy CommonJS scope to avoid mutating the
|
||||
host page's global `d3`. Only tree layout and hierarchy are used. No network
|
||||
request is made when opening an organization chart.
|
||||
2
src/vendor/d3-hierarchy/d3-hierarchy.min.js
vendored
Normal file
2
src/vendor/d3-hierarchy/d3-hierarchy.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
176
src/vendor/libphonenumber/LICENSE
vendored
Normal file
176
src/vendor/libphonenumber/LICENSE
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
43
src/vendor/lucide/LICENSE
vendored
Normal file
43
src/vendor/lucide/LICENSE
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
8
src/vendor/lucide/NOTICE.md
vendored
Normal file
8
src/vendor/lucide/NOTICE.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Lucide icons
|
||||
|
||||
Organization-chart controls use inline SVG geometry adapted from Lucide icons,
|
||||
with the extension's existing icon size, stroke and color styling.
|
||||
|
||||
Source: https://github.com/lucide-icons/lucide
|
||||
|
||||
The upstream ISC/MIT license is preserved in `LICENSE` and in generated scripts.
|
||||
Reference in New Issue
Block a user