Implemented the Organization Chart feature to create customer organizational

This commit is contained in:
2026-10-26 18:15:32 -03:00
parent bf48ed1dcb
commit 3229ce9c41
41 changed files with 4099 additions and 63 deletions

View File

@@ -1,5 +1,140 @@
(function () {
"use strict";
// Source: src/features/org-chart/model.js
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 };
}
// Source: src/features/org-chart/storage.js
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) };
}
// Source: src/shared/time-dates.js
function createTimeDates() {
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
@@ -157,6 +292,8 @@ function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
/** 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",
@@ -2939,6 +3076,8 @@ function startBackgroundApplication(dependencies) {
// Source: src/background/messages.js
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes,
requestWeekDetails,
requestTimeEntries,
@@ -2985,6 +3124,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 || [] }));
@@ -3209,6 +3353,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Source: src/background/application.js
function createBackgroundApplication(overrides = {}) {
const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
@@ -3217,6 +3362,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({
@@ -3384,6 +3530,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(),