diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRenderer.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRenderer.java
new file mode 100644
index 00000000..5a9f67ce
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/Aggregate2DTableWidgetRenderer.java
@@ -0,0 +1,202 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByGroupBy;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
+import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
+import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+
+
+/*******************************************************************************
+ ** Generic widget that does an aggregate query, and presents its results
+ ** as a table, using group-by values as both row & column labels.
+ *******************************************************************************/
+public class Aggregate2DTableWidgetRenderer extends AbstractWidgetRenderer
+{
+ private static final QLogger LOG = QLogger.getLogger(Aggregate2DTableWidgetRenderer.class);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public RenderWidgetOutput render(RenderWidgetInput input) throws QException
+ {
+ Map values = input.getWidgetMetaData().getDefaultValues();
+
+ String tableName = ValueUtils.getValueAsString(values.get("tableName"));
+ String valueField = ValueUtils.getValueAsString(values.get("valueField"));
+ String rowField = ValueUtils.getValueAsString(values.get("rowField"));
+ String columnField = ValueUtils.getValueAsString(values.get("columnField"));
+ QTableMetaData table = QContext.getQInstance().getTable(tableName);
+
+ AggregateInput aggregateInput = new AggregateInput();
+ aggregateInput.setTableName(tableName);
+
+ // todo - allow input of "list of columns" (e.g., in case some miss sometimes, or as a version of filter)
+ // todo - max rows, max cols?
+
+ // todo - from input map
+ QQueryFilter filter = new QQueryFilter();
+ aggregateInput.setFilter(filter);
+
+ Aggregate aggregate = new Aggregate(valueField, AggregateOperator.COUNT);
+ aggregateInput.withAggregate(aggregate);
+
+ GroupBy rowGroupBy = new GroupBy(table.getField(rowField));
+ GroupBy columnGroupBy = new GroupBy(table.getField(columnField));
+ aggregateInput.withGroupBy(rowGroupBy);
+ aggregateInput.withGroupBy(columnGroupBy);
+
+ String orderBys = ValueUtils.getValueAsString(values.get("orderBys"));
+ if(StringUtils.hasContent(orderBys))
+ {
+ for(String orderBy : orderBys.split(","))
+ {
+ switch(orderBy)
+ {
+ case "row" -> filter.addOrderBy(new QFilterOrderByGroupBy(rowGroupBy));
+ case "column" -> filter.addOrderBy(new QFilterOrderByGroupBy(columnGroupBy));
+ case "value" -> filter.addOrderBy(new QFilterOrderByAggregate(aggregate));
+ default -> LOG.warn("Unrecognized orderBy: " + orderBy);
+ }
+ }
+ }
+
+ AggregateOutput aggregateOutput = new AggregateAction().execute(aggregateInput);
+
+ Map> data = new LinkedHashMap<>();
+ Set columnsSet = new LinkedHashSet<>();
+
+ for(AggregateResult result : aggregateOutput.getResults())
+ {
+ Serializable column = result.getGroupByValue(columnGroupBy);
+ Serializable row = result.getGroupByValue(rowGroupBy);
+ Serializable value = result.getAggregateValue(aggregate);
+
+ Map rowMap = data.computeIfAbsent(row, (k) -> new LinkedHashMap<>());
+ rowMap.put(column, value);
+ columnsSet.add(column);
+ }
+
+ // todo - possible values from rows, cols
+
+ ////////////////////////////////////
+ // setup datastructures for table //
+ ////////////////////////////////////
+ List