And here is a Server Module which DOES correct grouping and passing the data to library (use below in show_sales_grid):
def
get_sales_2(item):
"""
Return parent + child rows for Jam.py + AG Grid Community
Flattened hierarchy with level + parent fields
"""
customers = task.invoices.copy()
invoices_ds = task.invoices.copy()
customers.open(
fields=['id', 'customer', 'firstname', 'total'],
funcs={'total': 'sum'},
group_by=['customer'],
order_by=['customer']
)
res = []
for rec in customers:
# Parent row (customer)
parent_row = {
'id': rec.customer.value, # unique customer ID
'name': f"{rec.firstname.lookup_value} {rec.customer.lookup_value}",
'total': round(rec.total.value, 2),
'level': 0,
'parent': None,
'visible': True,
'expanded': False # initially collapsed
}
res.append(parent_row)
# Child rows (individual invoices)
invoices_ds.open(fields=['id', 'total', 'customer'], order_by=['id'])
for inv in invoices_ds:
if inv.customer.value == rec.customer.value:
child_row = {
'id': inv.id.value, # invoice number
'name': f"INV-{inv.id.value}", # display invoice number
'total': round(inv.total.value, 2),
'level': 1,
'parent': rec.customer.value,
'visible': False # initially hidden
}
res.append(child_row)
return res
Which produces below. Compare results with Demo for ie: Julia Barnett.
The same data we would get by Searching on Invoices for Customer Barnett.
The above is completely done by ChatGPT, so really there is nothing stopping
anyone to extend the Jam features.
THE ONLY THING WE NEED TO HOW TO ADD LIBRARY AND SET THE TEMPLATE!
Everything else is AI trail and error.

Template (height/width is questionable here, but anyway):
<div class="ag-view">
<div id="myGrid" class="form-body ag-theme-alpine" style="height:800px; width:100%"></div>
</div>
index:

AG empty virtual table (similar to Dashboard):
function on_view_form_created(item) {
show_sales_grid(item);
}
function show_sales_grid(item) {
// Call server to get Python data
item.server('
get_sales_2', function(rowData) {
agGrid.ModuleRegistry.registerModules([
agGrid.ClientSideRowModelModule
]);
function renderGrid() {
var gridDiv = document.getElementById('myGrid');
// Destroy old grid if exists
if (gridDiv.__agGridInstance) {
gridDiv.__agGridInstance.destroy();
}
// Create new grid
var gridInstance = agGrid.createGrid(gridDiv, {
theme: 'legacy',
columnDefs: [
{
field: 'name',
headerName: 'Customer / Invoice',
cellRenderer: function(params) {
var indent = (params.data.level || 0) * 20;
var icon = '';
if (params.data.level === 0) {
icon = params.data.expanded ? '▾ ' : '▸ ';
}
var div = document.createElement('div');
div.style.paddingLeft = indent + 'px';
div.style.cursor = 'pointer';
div.innerHTML = icon + params.value;
if (params.data.level === 0) {
div.onclick = function() {
rowData.forEach(function(r){
if (r.parent ===
params.data.id) {
r.visible = !r.visible;
}
});
params.data.expanded = !params.data.expanded;
renderGrid(); // safely re-render
};
}
return div;
}
},
{
field: 'total',
headerName: 'Total',
valueFormatter: function(params) {
return params.value != null ? params.value.toFixed(2) : '';
}
}
],
rowData: rowData.filter(r => r.visible !== false),
rowSelection: { type: 'single', enableClickSelection: false }
});
// Save instance for next destroy
gridDiv.__agGridInstance = gridInstance;
}
renderGrid(); // initial render
});
}
All the best and this concludes my efforts for this year !
D.