contactcats/ang/crmContactcats.js
Rich Lott / Artful Robot 92db3be27e work on visualisation
2025-03-25 20:21:32 +00:00

463 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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. <crm-reset-password token='foo' />
// 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 <newCatID>: {
// // sources: {
// // string <previousCatId>: int <n>
// // },
// // now: int <n>, previous: int <n>
// // }}
//
// // 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. <crm-reset-password token='foo' />
// 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._);