(function(angular, $, _) { // Declare a list of dependencies. angular.module("crmContactcats", CRM.angRequires("crmContactcats")); angular.module("crmContactcats").component("crmContactCategoryFlows", { templateUrl: "~/crmContactcats/crmContactCategoryFlows.html", bindings: { // things listed here become properties on the controller using // values from attributes. @ means a fixed string is passed // e.g. // token: '@', // & is special. In the child, call ctrl.onMyAction with an // Object whose keys provide parameter names for the code in the parent. // onMyAction: '&', // '<' means one-way binding (parent»child) and I think is what you are // supposed to use for components. // v: '<' }, controller: function($scope, $timeout, crmApi4, crmStatus, $document) { var ts = ($scope.ts = CRM.ts(null)), ctrl = this; // function prepareSankey(analysis) { // // analysis is like: // // { string : { // // sources: { // // string : int // // }, // // now: int , previous: int // // }} // // // Snakes are vertically positioned cumulatively. // const snakes = ctrl.catDefs.forEach((cat, i) => { // // // }); // // // // Flatten to single array, then sort. // let allLabels = new Set(); // let maxPerLabel = 0; // let snakes = []; // console.log('analy', analysis); // // Object.entries(analysis).forEach(([newLabel, data]) => { // console.log({newLabel, data}); // allLabels.add(newLabel); // maxPerLabel = Math.max(maxPerLabel, data.now, data.previous); // Object.entries(data.sources).forEach(([previousLabel, n]) => { // allLabels.add(previousLabel); // snakes.push({ previousLabel, n, newLabel }); // }); // }); // // Get sorted list of labels for our axis // allLabels = Array.from(allLabels); // allLabels.sort(labelSort); // console.log(allLabels); // // // Sort the snakes // snakes.sort((a, b) => { // // We want retentions first. // if (a.previousLabel === a.newLabel && b.previousLabel !== b.newLabel) return -1; // if (a.previousLabel !== a.newLabel && b.previousLabel === b.newLabel) return 1; // // Next we want destination labels // let c = allLabels.indexOf(b.newLabel) - allLabels.indexOf(a.newLabel); // if (c !== 0) return c; // c = allLabels.indexOf(a.previousLabel) - allLabels.indexOf(b.previousLabel); // if (c !== 0) return c; // // By size. // return b.n - a.n; // }); // // const labelWidth = 150, // width = Math.max(600, document.getElementById('rfm-width').firstElementChild.clientWidth), // domainWidth = width - 2*labelWidth; // ctrl.sankey = { // height: Math.min(600, allLabels.length * 100), /* allow up to 100px per label */ // width, // labelWidth, // rows: snakes // }; // // const heightPerLabel = Math.floor(ctrl.sankey.height / allLabels.length); // const padding = 24; // const yScale = (heightPerLabel - padding) / maxPerLabel; // const labelYs = {}; // allLabels.forEach((label, i) => { // const y = i*heightPerLabel; // labelYs[label] = { initial: y, previous: y, now: y }; // }); // ctrl.sankey.labels = allLabels.map(label => ({label: label || '(Uncategorised)', y: labelYs[label].initial})); // console.log("Sankey", {heightPerLabel, padding, yScale, labelYs, width: ctrl.sankey.width, snakes, allLabels}); // // let g=1; // gradient ID // ctrl.sankey.rows.forEach(row => { // let yPrevious = labelYs[row.previousLabel].previous, // h = yScale * row.n, // yNow = labelYs[row.newLabel].now; // labelYs[row.previousLabel].previous += h; // labelYs[row.newLabel].now += h; // // Create path // row.d = `M${labelWidth},${yPrevious} l16,0 // C ${labelWidth + domainWidth/4},${yPrevious} ${labelWidth + domainWidth*3/4},${yNow} ${ctrl.sankey.width - labelWidth -16},${yNow} // l16,0 // l0,${h} // l-16,0 // C ${labelWidth + domainWidth*3/4},${yNow+h} ${labelWidth + domainWidth/4},${yPrevious+h} ${labelWidth+16},${yPrevious + h} // l-16,0 z`; // row.fill = `hsl(${ctrl.getHueFromLabel(row.previousLabel)}, 50%, 50%)`; // row.fill = `url(#g${g})`; // row.gradient = {id: 'g' + g++, // to: `hsl(${ctrl.getHueFromLabel(row.previousLabel)}, 50%, 50%)`, // from: `hsl(${ctrl.getHueFromLabel(row.newLabel)}, 50%, 50%)`, // }; // }); // // // console.log("Sankey", {sankey: ctrl.sankey}); // } // this.$onInit gets run after the this controller is called, and after the bindings have been applied. this.$onInit = async function() { // Default start date to 1 month ago. ctrl.loading = true; ctrl.startDate = new Date(); ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1); ctrl.startDate.setHours(0); ctrl.startDate.setMinutes(0); ctrl.startDate.setSeconds(0); ctrl.startDate = new Date('2025-03-10'); ctrl.endDate = null; ctrl.stats = null; ctrl.catDefs = await crmStatus({ start: ts('Loading...'), success: ''}, crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true }) ); ctrl.loading = false; console.log(ctrl.catDefs); ctrl.fetchStats = function() { ctrl.loading = true; crmApi4('ContactCategory', 'getFlows', { startDate: ctrl.startDate.toISOString().substring(0, 10), endDate: ctrl.endDate ? ctrl.endDate.toISOString().substring(0, 10) : '', }).then(r => { // Process the results into something useful. ctrl.loading = false; console.log("results", r); if (!(r.length)) { ctrl.noStats = true; ctrl.stats = []; return; } ctrl.noStats = false; // Create main analysis structure, indexed by right hand side of snakes. const analysis = {}, offsetWithinSourcesByCat = {}; ctrl.catDefs.forEach(cat => { analysis[cat.id] = { sources: [], now: 0, previous: 0, cat }; offsetWithinSourcesByCat[cat.id] = 0; }); let totalContacts = 0; r.forEach(row => { const {from_category_id, to_category_id, contact_count} = row; analysis[to_category_id].now += contact_count; if (!from_category_id) { // from nowhere: ignore for now. console.log("Skipping", row); return; } analysis[from_category_id].previous += contact_count; analysis[to_category_id].sources.push({ cat: ctrl.catDefs[from_category_id], contact_count }); totalContacts += analysis[to_category_id].now; }); // allowing 50px per cat let scale = (ctrl.catDefs.length * 50) / totalContacts; // Make sure that our 'from' snake ends are ordered by category presentation_order // This avoids one categories snakes overlapping its others. ctrl.catDefs.forEach(cat => { analysis[cat.id].sources.sort((a, b) => { return a.cat.presentation_order - b.cat.presentation_order; }) }); // Make a list of snakes we need to draw. const snakes = []; let yTopR = 0; ctrl.catDefs.forEach(cat => { let toCat = analysis[cat.id]; toCat.yTop = yTopR; toCat.sources.forEach(source => { snakes.push({ toTop : yTopR, toHeight : source.contact_count * scale, toCat, fromTopOffset: offsetWithinSourcesByCat[source.cat.id], fromHeight: source.contact_count * scale, fromCat: source.cat }); offsetWithinSourcesByCat[source.cat.id] += source.contact_count * scale; yTopR += source.contact_count * scale; }); yTopR += 8;// padding }); ctrl.snakes = snakes; console.log({snakes, catDefs: ctrl.catDefs, analysis}); }); } // do initial fetch. ctrl.fetchStats(); }; }, }); angular.module("crmContactcats").component("crmContactCategorySettings", { templateUrl: "~/crmContactcats/crmContactCategorySettings.html", bindings: { // things listed here become properties on the controller using // values from attributes. @ means a fixed string is passed // e.g. // token: '@', // & is special. In the child, call ctrl.onMyAction with an // Object whose keys provide parameter names for the code in the parent. // onMyAction: '&', // '<' means one-way binding (parent»child) and I think is what you are // supposed to use for components. // v: '<' }, controller: function($scope, $timeout, crmApi4, crmStatus, $document) { var ts = ($scope.ts = CRM.ts(null)), ctrl = this; // this.$onInit gets run after the this controller is called, and after the bindings have been applied. this.$onInit = async function() { ctrl.dirty = 'pristine'; //pristine|dirty ctrl.view = 'list'; ctrl.uxOrderField = 'execution_order'; ctrl.moveIdx = null; ctrl.categoryToEdit = null; ctrl.presentation_order = []; ctrl.execution_order = []; const blankDefn = { id: 0, execution_order: 10, presentation_order: 10, label:'', search_type: 'search', search_data: {}, color: '#666666', icon: 'fa-circle-half-stroke', description: '', }; catDefs = await crmApi4("ContactCategoryDefinition", 'get', { orderBy: { execution_order: 'ASC' }, withLabels: true }); if (catDefs.count === 0) { // First time. catDefs = []; } // Ensure we have the minimum of what we need. if (!catDefs.find(c => c.search_type === 'default')) { // There's no default one. catDefs.push(Object.assign({}, blankDefn, { execution_order: catDefs.length, presentation_order: catDefs.length, label: ts('Default'), search_type: 'default', icon: 'fa-person-circle-question', description: ts('Any contact not matching any of the categories is assigned this category.'), })); } // Ensure the fields are all present in the data. catDefs.forEach((item, idx) => { catDefs[idx] = Object.assign({}, blankDefn, item); }); // Create ordered arrays. ['presentation_order', 'execution_order'].forEach(orderField => { ctrl[orderField] = catDefs.slice(); ctrl[orderField].sort((a, b) => { // console.log({orderField, aST: a.search_type, bST: b.search_type, aOrd:a[orderField], bOrd:b[orderField], aLab:a.label, bLab:b.label}); if (a.search_type === 'default' && b.search_type !== 'default') return 1; if (b.search_type === 'default' && a.search_type !== 'default') return -1; if (a[orderField] > b[orderField]) return 1; if (a[orderField] < b[orderField]) return -1; if (a.label < b.label) return 1; if (a.label > b.label) return -1; return 0; }); }); updateOrders(); $scope.$digest(); // Functions follow. /** * Copy execution to presenation or vice versa. */ ctrl.copyOrder = (from) => { ctrl[ctrl.uxOrderField] = ctrl[from].slice(); updateOrders(); }; /** * We set execution_order to the array index to keep things simpler. */ function updateOrders() { // Update execution_order or presentation_order field to match the specified order. ctrl[ctrl.uxOrderField].forEach((item, idx) => { item[ctrl.uxOrderField] = idx; }); } /** * Begin editing something */ ctrl.edit = idx => { if (idx === -1) { // New item. // Create a random dark colour let [r, g, b] = [Math.random(), Math.random(), Math.random()], min = Math.min(r, g, b), range = Math.max(r, g, b) - min, conv = (x) => ('0' + Math.ceil((x-min)/range * 128).toString(16)).replace(/^.*(..)$/, '$1'); // Create a blank ctrl.categoryToEdit = Object.assign({}, blankDefn, { execution_order: -1, presentation_order: -1, search_type: 'search', search_data: { saved_search_id: null }, color: '#' + [r, g, b].map(conv).join(''), }); } else { ctrl.categoryToEdit = Object.assign({}, JSON.parse(JSON.stringify(catDefs[idx]))); } ctrl.view = 'edit'; }; /** * End editing something */ ctrl.updateEditedThing = () => { const edited = ctrl.categoryToEdit; // @todo validate, e.g. if (!edited.label) { alert("No name"); return; } if (edited.execution_order === -1) { // This is a new category, we need to insert it before the default one. const newIdx = ctrl.execution_order.length - 1; edited.presentation_order = newIdx; edited.execution_order = newIdx; ctrl.execution_order.splice(newIdx, 0, edited); ctrl.presentation_order.splice(newIdx, 0, edited); } else { console.log("Update:", ctrl.uxOrderField); const targetCat = ctrl[ctrl.uxOrderField][edited[ctrl.uxOrderField]]; Object.keys(blankDefn).forEach(k => { console.log("Setting", k, "to ", edited[k], 'on item', targetCat); targetCat[k] = edited[k]; }); } ctrl.categoryToEdit = null; updateOrders(); ctrl.dirty = 'dirty'; ctrl.view = 'list'; } /** * Move a category within the currently selected order. */ ctrl.moveTo = idx => { let item = ctrl[ctrl.uxOrderField].splice(ctrl.moveIdx, 1)[0]; if (idx > ctrl.moveIdx) { idx--; } ctrl[ctrl.uxOrderField].splice(idx, 0, item); ctrl.moveIdx = null; updateOrders(); ctrl.dirty = 'dirty'; }; /** * Mark a category for deletion. */ ctrl.deleteCategory = idx => { if (!confirm(ts( 'Confirm deleting category ‘%1’. You will lose history related to this category. Sure?', {1: catDefs[idx].label} ))) { return; } ctrl[ctrl.uxOrderField][idx].deleted = true; ctrl.categoryToEdit = null; updateOrders(); ctrl.view = 'list'; ctrl.dirty = 'dirty'; }; /** * We need to ensure the search_data object contains the * expected fields and no more. */ ctrl.fixSearchData = () => { const search_data = ctrl.categoryToEdit.search_data; if (ctrl.categoryToEdit.search_type === 'group') { Object.assign(search_data, {group_id: null}); let {group_id} = search_data; ctrl.categoryToEdit.search_data = {group_id}; } else if (ctrl.categoryToEdit.search_type === 'search') { Object.assign(search_data, {saved_search_id: null}); let {saved_search_id} = search_data; ctrl.categoryToEdit.search_data = {saved_search_id}; } }; ctrl.save = () => { if (!confirm(ts("Confirm saving changes to categories? Note that categories will not be fully applied until tomorrow."))) { return; } const records = ctrl[ctrl.uxOrderField]; // Handle deletions first. const deletedIds = records.filter(d => d.deleted && d.id > 0).map(d => d.id); const chain = Promise.resolve(); if (deletedIds.length) { chain.then(() => crmApi4('ContactCategoryDefinition', 'delete', { where: [ ['id', 'IN', deletedIds] ] })); } // Now enact the deletions on our local models ctrl.presentation_order = ctrl.presentation_order.filter(d => !d.deleted); ctrl.execution_order = ctrl.execution_order.filter(d => !d.deleted); updateOrders(); if (ctrl.presentation_order.length) { // Save if anything to save. chain.then(() => crmApi4('ContactCategoryDefinition', 'save', {records})); } chain.then(() => { ctrl.dirty = 'pristine'; }) crmStatus({ start: ts('Saving...'), success: ts('Saved')}, chain); }; }; // this.$onChange = function(changes) { // // changes is object keyed by name what '<' binding changed in // // the parent (e.g. 'v'), and value is another obj with keys // // something like previous, new, ...?... // }; } }); })(angular, CRM.$, CRM._);