mirror of
https://codeberg.org/artfulrobot/contactcats.git
synced 2025-06-25 21:28:06 +02:00
463 lines
18 KiB
JavaScript
463 lines
18 KiB
JavaScript
(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._);
|