From fc92690573dfcae8e5ca666d9ed216693b491b6a Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:38:12 -0500 Subject: [PATCH 1/3] feat(app): one crm app with main and settings sections; processes never in nav Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/crm/CrmAppMetaData.java | 117 ++++++++++++ .../kingsrook/qbits/crm/CrmQBitConfig.java | 20 +++ .../kingsrook/qbits/crm/CrmQBitProducer.java | 35 +++- .../qbits/crm/CrmAppMetaDataTest.java | 169 ++++++++++++++++++ 4 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java new file mode 100644 index 0000000..d1c0523 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/CrmAppMetaData.java @@ -0,0 +1,117 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qbits.crm.widgets.CrmHomeWidgets; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** The CRM home and the two navigation sections. Child tables stay on records. + *******************************************************************************/ +public final class CrmAppMetaData extends QAppMetaData implements SourceQBitAware +{ + private String sourceQBitName; + + public static final List MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list"); + public static final List SETTINGS_TABLES = List.of("crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence", + "crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_currency", "crm_lifecycle_stage", "crm_lead_source", + "crm_industry", "crm_activity_type", "crm_activity_outcome", "crm_contact_role", "crm_win_loss_reason"); + + + /******************************************************************************* + ** Metadata factory only. + *******************************************************************************/ + private CrmAppMetaData() + { + } + + + /******************************************************************************* + ** Main navigation, also available to hosts embedding these tables. + *******************************************************************************/ + public static QAppSection mainSection() + { + return (new QAppSection().withName("crm").withLabel("CRM").withIcon(new QIcon("contacts")) + .withTables(new ArrayList<>(MAIN_TABLES))); + } + + + /******************************************************************************* + ** Construct the app after package discovery has produced the tables. + *******************************************************************************/ + public static QAppMetaData build(MetaDataProducerMultiOutput output, String source) + { + QAppMetaData app = new CrmAppMetaData().withSourceQBitName(source).withName("crm").withLabel("CRM").withIcon(new QIcon("contacts")) + .withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE)) + .withWidgets(new ArrayList<>(CrmHomeWidgets.HOME)) + .withSection(mainSection()) + .withSection(new QAppSection().withName("crm_settings").withLabel("Settings").withIcon(new QIcon("settings")) + .withTables(new ArrayList<>(SETTINGS_TABLES))); + List tables = new ArrayList<>(MAIN_TABLES); + tables.addAll(SETTINGS_TABLES); + for(String name : tables) + { + app.withChild(output.get(QTableMetaData.class, name)); + } + return (app); + } + + /******************************************************************************* + ** QBit provenance, until core apps implement SourceQBitAware. + *******************************************************************************/ + @Override + public String getSourceQBitName() + { + return (sourceQBitName); + } + + + /******************************************************************************* + ** QBit provenance, until core apps implement SourceQBitAware. + *******************************************************************************/ + @Override + public void setSourceQBitName(String sourceQBitName) + { + this.sourceQBitName = sourceQBitName; + } + + + /******************************************************************************* + ** QBit provenance, until core apps implement SourceQBitAware. + *******************************************************************************/ + @Override + public CrmAppMetaData withSourceQBitName(String sourceQBitName) + { + setSourceQBitName(sourceQBitName); + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java index 053065c..8d4ed7f 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java @@ -45,6 +45,7 @@ public class CrmQBitConfig implements QBitConfig private String defaultBackendNameForTables; private Boolean inferBackendNames = true; + private Boolean appEnabled = true; private MetaDataCustomizerInterface tableMetaDataCustomizer; private List securityFields; private List recordSecurityLocks; @@ -188,4 +189,23 @@ public class CrmQBitConfig implements QBitConfig return (this); } + + /******************************************************************************* + ** Whether to publish the standalone CRM app. + *******************************************************************************/ + public Boolean getAppEnabled() + { + return (appEnabled); + } + + + /******************************************************************************* + ** Allow a host to put CRM tables in its own app. + *******************************************************************************/ + public CrmQBitConfig withAppEnabled(Boolean appEnabled) + { + this.appEnabled = appEnabled; + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 1ccdec2..0a264cc 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -26,6 +26,7 @@ package com.kingsrook.qbits.crm; +import java.util.List; import java.util.Locale; import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer; import com.kingsrook.qbits.crm.activities.model.Activity; @@ -58,7 +59,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; /******************************************************************************* @@ -104,13 +107,32 @@ public class CrmQBitProducer implements QBitMetaDataProducer for(QWidgetMetaData widget : metaDataProducerMultiOutput.getEach(QWidgetMetaData.class)) { widget.setName(snakeCase(widget.getName())); - widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE)); + QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker(); + widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker)); + widget.setGridColumns(12); if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName) { - widget.withDefaultValue("joinName", snakeCase(joinName)); + String normalizedJoinName = snakeCase(joinName); + widget.withDefaultValue("joinName", normalizedJoinName); + QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream() + .filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow(); + QTableMetaData parent = metaDataProducerMultiOutput.get(QTableMetaData.class, join.getLeftTable()); + boolean alreadyAttached = parent.getSections() != null && parent.getSections().stream() + .anyMatch(section -> section.getWidgetName() != null && snakeCase(section.getWidgetName()).equals(widget.getName())); + if(!alreadyAttached) + { + parent.withSection(new QFieldSection(widget.getName(), widget.getLabel(), new QIcon("list"), Tier.T2, List.of()) + .withWidgetName(widget.getName())); + } } } + metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_form_submission").setLabel("Web Leads"); + metaDataProducerMultiOutput.get(QTableMetaData.class, "crm_email_list").setLabel("Lists"); + metaDataProducerMultiOutput.get(QTableMetaData.class, Contact.TABLE_NAME).withSection( + new QFieldSection("crm_contact_timeline", "Timeline", new QIcon("history"), Tier.T2, List.of()) + .withWidgetName("crm_contact_timeline")); + for(QProcessMetaData process : metaDataProducerMultiOutput.getEach(QProcessMetaData.class)) { process.setPermissionRules(CrmPermissions.processRules(process.getName())); @@ -234,10 +256,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer *******************************************************************************/ public static QAppSection produceAppSection() { - return new QAppSection() - .withName("crm") - .withLabel("CRM") - .withIcon(new QIcon().withName("contacts")); + return (CrmAppMetaData.mainSection()); } @@ -293,6 +312,10 @@ public class CrmQBitProducer implements QBitMetaDataProducer output.add(item instanceof QWidgetMetaData widget ? new CrmWidgetMetaData(widget).withSourceQBitName(getQBitMetaData().getName()) : item); } + if(!Boolean.FALSE.equals(qBitConfig.getAppEnabled())) + { + output.add(CrmAppMetaData.build(output, getQBitMetaData().getName())); + } return (output); } diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java new file mode 100644 index 0000000..acdabd2 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java @@ -0,0 +1,169 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm; + + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.widgets.CrmHomeWidgets; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** App navigation and record sections are tested against the validated instance. + *******************************************************************************/ +class CrmAppMetaDataTest extends BaseTest +{ + /******************************************************************************* + ** Exactly six main and fifteen settings tables, no sub-tables or processes. + *******************************************************************************/ + @Test + void app() + { + QAppMetaData app = QContext.getQInstance().getApp("crm"); + assertThat(QContext.getQInstance().getApps()).hasSize(1); + assertThat(app.getLabel()).isEqualTo("CRM"); + assertThat(app.getIcon().getName()).isEqualTo("contacts"); + assertThat(app.getSections()).hasSize(2); + assertThat(app.getSections().get(0).getTables()).containsExactly("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list"); + assertThat(app.getSections().get(1).getTables()).containsExactly("crm_pipeline", "crm_product", "crm_email_template", "crm_email_sequence", + "crm_lead_score_rule", "crm_assignment_rule", "crm_sales_goal", "crm_currency", "crm_lifecycle_stage", "crm_lead_source", "crm_industry", + "crm_activity_type", "crm_activity_outcome", "crm_contact_role", "crm_win_loss_reason"); + assertThat(app.getSections().get(1).getLabel()).isEqualTo("Settings"); + for(QAppSection section : app.getSections()) + { + assertThat(section.getProcesses()).isNullOrEmpty(); + } + assertThat(app.getChildren()).hasSize(21); + assertThat(app.getWidgets()).containsExactlyElementsOf(CrmHomeWidgets.HOME); + assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("crm"); + assertThat(app.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION); + assertThat(((SourceQBitAware) app).getSourceQBitName()).isEqualTo("com.kingsrook.qbits:crm"); + assertThat(CrmQBitProducer.produceAppSection().getTables()).containsExactlyElementsOf(CrmAppMetaData.MAIN_TABLES); + assertThat(QContext.getQInstance().getTable("crm_form_submission").getLabel()).isEqualTo("Web Leads"); + assertThat(QContext.getQInstance().getTable("crm_email_list").getLabel()).isEqualTo("Lists"); + } + + + /******************************************************************************* + ** Hosts can embed tables without the standalone app; null keeps the default. + *******************************************************************************/ + @Test + void optOut() throws Exception + { + assertThat(new CrmQBitConfig().getAppEnabled()).isTrue(); + for(Boolean enabled : new Boolean[] {false, true, null}) + { + QInstance instance = new QInstance(); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME).withAppEnabled(enabled)) + .produce(instance).addSelfToInstance(instance); + assertThat(instance.getApps()).hasSize(Boolean.FALSE.equals(enabled) ? 0 : 1); + assertThat(instance.getTables()).hasSize(33); + } + } + + + /******************************************************************************* + ** Every child widget has a real join and both joined fields exist. + *******************************************************************************/ + @Test + void childLists() + { + Map> expected = Map.of( + "crm_contact", Set.of("crm_deal", "crm_activity", "crm_email_list_member", "crm_consent_record", "crm_sequence_enrollment", "crm_email_message", "crm_form_submission"), + "crm_company", Set.of("crm_contact", "crm_deal", "crm_activity"), + "crm_deal", Set.of("crm_deal_contact", "crm_deal_product", "crm_deal_stage_history", "crm_activity"), + "crm_pipeline", Set.of("crm_pipeline_stage"), "crm_email_sequence", Set.of("crm_sequence_step", "crm_sequence_enrollment"), + "crm_email_list", Set.of("crm_email_list_member"), "crm_assignment_rule", Set.of("crm_assignment_rule_member"), + "crm_activity", Set.of("crm_activity_participant", "crm_email_message")); + QInstance instance = QContext.getQInstance(); + expected.forEach((parent, children) -> + { + Set actual = instance.getTable(parent).getSections().stream().filter(section -> section.getWidgetName() != null) + .map(section -> instance.getWidget(section.getWidgetName())) + .filter(widget -> WidgetType.CHILD_RECORD_LIST.getType().equals(widget.getType())) + .map(widget -> + { + String joinName = (String) widget.getDefaultValues().get("joinName"); + assertThat(joinName).matches("crm_[a-z0-9_]+"); + QJoinMetaData join = instance.getJoin(joinName); + assertThat(join).isNotNull(); + assertThat(join.getLeftTable()).isEqualTo(parent); + join.getJoinOns().forEach(on -> + { + assertThat(instance.getTable(parent).getField(on.getLeftField())).isNotNull(); + assertThat(instance.getTable(join.getRightTable()).getField(on.getRightField())).isNotNull(); + }); + if(parent.equals("crm_contact") && join.getRightTable().equals("crm_deal")) + { + assertThat(join.getJoinOns().getFirst().getRightField()).isEqualTo("primaryContactId"); + } + return (join.getRightTable()); + }).collect(Collectors.toSet()); + assertThat(actual).isEqualTo(children); + }); + assertThat(instance.getTable("crm_contact").getSections()).extracting(QFieldSection::getWidgetName).contains("crm_contact_timeline"); + } + + + /******************************************************************************* + ** No retired trends; all widgets have explicit protection and full width. + *******************************************************************************/ + @Test + void widgets() + { + QInstance instance = QContext.getQInstance(); + assertThat(instance.getWidgets()).doesNotContainKeys("crm_win_rate", "crm_stage_duration", "crm_sales_cycle_length", "crm_pipeline_conversion", "crm_avg_deal_size", "crm_activity_feed"); + for(QWidgetMetaDataInterface widget : instance.getWidgets().values()) + { + assertThat(widget.getPermissionRules()).isNotNull(); + assertThat(widget.getGridColumns()).isEqualTo(12); + assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType()); + } + } + + + /******************************************************************************* + ** Primary-contact accessors round-trip the new child-list relationship. + *******************************************************************************/ + @Test + void primaryContact() + { + Deal deal = new Deal().withPrimaryContactId(7); + assertThat(deal.getPrimaryContactId()).isEqualTo(7); + deal.setPrimaryContactId(8); + assertThat(deal.getPrimaryContactId()).isEqualTo(8); + } +} -- 2.54.0 From 85b83c80319be30a9401ceb7a44a4842e8b27fac Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 12:38:13 -0500 Subject: [PATCH 2/3] feat(home): real-data CRM home; retire trend widgets for data analysis Co-Authored-By: Claude Opus 5.5 --- .../crm/widgets/CrmActivityCountRenderer.java | 128 ----- .../CrmActivityCountWidgetProducer.java | 68 --- .../crm/widgets/CrmActivityFeedRenderer.java | 131 ----- .../CrmActivityFeedWidgetProducer.java | 67 --- .../crm/widgets/CrmAvgDealSizeRenderer.java | 143 ----- .../widgets/CrmAvgDealSizeWidgetProducer.java | 66 --- .../widgets/CrmContactTimelineRenderer.java | 107 +--- .../CrmContactTimelineWidgetProducer.java | 10 +- .../crm/widgets/CrmDealForecastRenderer.java | 189 ------- .../CrmDealForecastWidgetProducer.java | 67 --- .../qbits/crm/widgets/CrmGoalsVisibility.java | 72 +++ .../qbits/crm/widgets/CrmHomeRenderer.java | 496 ++++++++++++++++++ .../qbits/crm/widgets/CrmHomeWidgets.java | 81 +++ .../CrmPipelineConversionRenderer.java | 144 ----- .../CrmPipelineConversionWidgetProducer.java | 67 --- .../widgets/CrmPipelineSummaryRenderer.java | 179 ------- .../CrmPipelineSummaryWidgetProducer.java | 67 --- .../widgets/CrmQuotaAttainmentRenderer.java | 171 ------ .../CrmQuotaAttainmentWidgetProducer.java | 68 --- .../crm/widgets/CrmRecentDealsRenderer.java | 131 ----- .../widgets/CrmRecentDealsWidgetProducer.java | 66 --- .../crm/widgets/CrmRottingDealsRenderer.java | 186 ------- .../CrmRottingDealsWidgetProducer.java | 67 --- .../widgets/CrmSalesCycleLengthRenderer.java | 147 ------ .../CrmSalesCycleLengthWidgetProducer.java | 66 --- .../widgets/CrmSalesLeaderboardRenderer.java | 137 ----- .../CrmSalesLeaderboardWidgetProducer.java | 68 --- .../crm/widgets/CrmStageDurationRenderer.java | 145 ----- .../CrmStageDurationWidgetProducer.java | 66 --- .../qbits/crm/widgets/CrmWinRateRenderer.java | 170 ------ .../crm/widgets/CrmWinRateWidgetProducer.java | 66 --- .../widgets/CrmActivityCountWidgetTest.java | 145 ----- .../widgets/CrmActivityFeedWidgetTest.java | 155 ------ .../crm/widgets/CrmAvgDealSizeWidgetTest.java | 172 ------ .../widgets/CrmContactTimelineWidgetTest.java | 182 ++----- .../widgets/CrmDealForecastWidgetTest.java | 197 ------- .../qbits/crm/widgets/CrmHomeWidgetsTest.java | 357 +++++++++++++ .../CrmPipelineConversionWidgetTest.java | 148 ------ .../widgets/CrmPipelineSummaryWidgetTest.java | 206 -------- .../widgets/CrmQuotaAttainmentWidgetTest.java | 241 --------- .../crm/widgets/CrmRecentDealsWidgetTest.java | 177 ------- .../widgets/CrmRottingDealsWidgetTest.java | 266 ---------- .../CrmSalesCycleLengthWidgetTest.java | 147 ------ .../CrmSalesLeaderboardWidgetTest.java | 149 ------ .../widgets/CrmStageDurationWidgetTest.java | 181 ------- .../crm/widgets/CrmWinRateWidgetTest.java | 175 ------ 46 files changed, 1072 insertions(+), 5462 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetProducer.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmGoalsVisibility.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java create mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationWidgetProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateRenderer.java delete mode 100644 src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateWidgetProducer.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetTest.java create mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationWidgetTest.java delete mode 100644 src/test/java/com/kingsrook/qbits/crm/widgets/CrmWinRateWidgetTest.java diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountRenderer.java deleted file mode 100644 index f567a90..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountRenderer.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Activity Count widget. - ** - ** Queries activities created today and groups them by activityTypeId to - ** produce counts. Returns a MultiStatisticsData with one entry per known - ** type: Call, Email, Meeting. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.MultiStatisticsData; -import com.kingsrook.qqq.backend.core.model.data.QRecord; - - -/******************************************************************************* - ** Renders activity counts by type for today as a multi-statistics widget. - *******************************************************************************/ -public class CrmActivityCountRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - //////////////////////////////////////////////////////// - // determine start-of-day in the system's time zone // - //////////////////////////////////////////////////////// - ZoneId zoneId = ZoneId.systemDefault(); - ZonedDateTime startOfDay = LocalDate.now(zoneId).atStartOfDay(zoneId); - Instant todayStart = startOfDay.toInstant(); - - //////////////////////////////////////////////////////////////// - // query all activity types for labeling // - //////////////////////////////////////////////////////////////// - Map typeNameMap = new HashMap<>(); - QueryOutput typeOutput = new QueryAction().execute( - new QueryInput(ActivityType.TABLE_NAME)); - for(QRecord typeRecord : typeOutput.getRecords()) - { - typeNameMap.put(typeRecord.getValueInteger("id"), typeRecord.getValueString("name")); - } - - //////////////////////////////////////////////////////////// - // query activities created today // - //////////////////////////////////////////////////////////// - QueryOutput activityOutput = new QueryAction().execute( - new QueryInput(Activity.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, todayStart)))); - - //////////////////////////////////////////////////////////// - // count by activityTypeId // - //////////////////////////////////////////////////////////// - Map countByType = new HashMap<>(); - for(QRecord record : activityOutput.getRecords()) - { - Integer typeId = record.getValueInteger("activityTypeId"); - if(typeId != null) - { - countByType.merge(typeId, 1, Integer::sum); - } - } - - //////////////////////////////////////////////////////////// - // build statistics group data // - //////////////////////////////////////////////////////////// - List groups = new ArrayList<>(); - for(Map.Entry entry : countByType.entrySet()) - { - String typeName = typeNameMap.getOrDefault(entry.getKey(), "Type " + entry.getKey()); - groups.add(new MultiStatisticsData.StatisticsGroupData() - .withHeader(typeName) - .withSubheader("today") - .withStatisticList(List.of( - new MultiStatisticsData.StatisticsGroupData.Statistic(typeName, entry.getValue(), null) - ))); - } - - MultiStatisticsData data = new MultiStatisticsData("Activity Counts (Today)", groups); - - return (new RenderWidgetOutput(data)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetProducer.java deleted file mode 100644 index 8d583a7..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetProducer.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Activity Count widget. - ** - ** Provides a MULTI_STATISTICS view showing counts of activities by type for - ** the current user today: calls, emails, meetings. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; - - -/******************************************************************************* - ** Produces metadata for the crm_activity_count widget (MULTI_STATISTICS type). - *******************************************************************************/ -public class CrmActivityCountWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_activity_count"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.MULTI_STATISTICS.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Activity Counts (Today)") - .withTooltip("Activity counts by type for today") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new QIcon("analytics")) - .withCodeReference(new QCodeReference(CrmActivityCountRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedRenderer.java deleted file mode 100644 index 4f25bae..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedRenderer.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Activity Feed widget. - ** - ** Queries the most recent 50 activities (by createDate descending) and returns - ** a table with columns: subject, activityType, contact, company, date. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders the activity feed as a table widget showing recent activities. - *******************************************************************************/ -public class CrmActivityFeedRenderer extends AbstractWidgetRenderer -{ - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter - .ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()); - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // query recent activities, ordered by createDate desc // - /////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(Activity.TABLE_NAME); - queryInput.setFilter(new QQueryFilter() - .withOrderBy(new QFilterOrderBy("createDate", false)) - .withLimit(50)); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Subject", "subject", "2fr", null), - new TableData.Column("html", "Activity Type", "activityType", "1fr", null), - new TableData.Column("html", "Contact", "contact", "1fr", null), - new TableData.Column("html", "Company", "company", "1fr", null), - new TableData.Column("html", "Date", "date", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(QRecord record : queryOutput.getRecords()) - { - String subject = record.getValueString("subject"); - if(subject == null) - { - subject = ""; - } - - Integer activityTypeId = record.getValueInteger("activityTypeId"); - String activityTypeDisplay = activityTypeId != null ? String.valueOf(activityTypeId) : "-"; - - Integer contactId = record.getValueInteger("contactId"); - String contactDisplay = contactId != null ? String.valueOf(contactId) : "-"; - - Integer companyId = record.getValueInteger("companyId"); - String companyDisplay = companyId != null ? String.valueOf(companyId) : "-"; - - Instant createDate = record.getValueInstant("createDate"); - String dateDisplay = createDate != null ? DATE_FORMATTER.format(createDate) : "-"; - - rows.add(MapBuilder.of( - "subject", subject, - "activityType", activityTypeDisplay, - "contact", contactDisplay, - "company", companyDisplay, - "date", dateDisplay - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(50) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetProducer.java deleted file mode 100644 index aa4a762..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetProducer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Activity Feed widget. - ** - ** Provides a TABLE view showing the most recent 50 activities across the CRM - ** with columns: subject, activityType, contact, company, date. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_activity_feed widget (TABLE type). - *******************************************************************************/ -public class CrmActivityFeedWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_activity_feed"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Activity Feed") - .withTooltip("Recent CRM activities") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("event_note")) - .withCodeReference(new QCodeReference(CrmActivityFeedRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeRenderer.java deleted file mode 100644 index 9cca0e2..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeRenderer.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Average Deal Size widget. - ** - ** Queries deals where actualCloseDate is set and stage is closedWon. - ** Computes average amountInBaseCurrency and returns as a single-stat table. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders average deal size as a single-stat table widget. - *******************************************************************************/ -public class CrmAvgDealSizeRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // find closedWon stage IDs // - /////////////////////////////////////////////////////////// - Set closedWonStageIds = new HashSet<>(); - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true)))); - - for(QRecord record : stageOutput.getRecords()) - { - closedWonStageIds.add(record.getValueInteger("id")); - } - - /////////////////////////////////////////////////////////// - // query deals with actualCloseDate set // - /////////////////////////////////////////////////////////// - QueryOutput dealOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK)))); - - /////////////////////////////////////////////////////////// - // compute average for closedWon deals // - /////////////////////////////////////////////////////////// - BigDecimal totalAmount = BigDecimal.ZERO; - int count = 0; - - for(QRecord record : dealOutput.getRecords()) - { - Integer stageId = record.getValueInteger("pipelineStageId"); - if(closedWonStageIds.contains(stageId)) - { - BigDecimal amount = record.getValueBigDecimal("amountInBaseCurrency"); - if(amount == null) - { - amount = record.getValueBigDecimal("amount"); - } - if(amount != null) - { - totalAmount = totalAmount.add(amount); - count++; - } - } - } - - BigDecimal avgDealSize = count > 0 - ? totalAmount.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP) - : BigDecimal.ZERO; - - /////////////////////////// - // build table output // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Metric", "metric", "1fr", null), - new TableData.Column("html", "Value", "value", "1fr", "right") - ); - - List> rows = new ArrayList<>(); - rows.add(MapBuilder.of( - "metric", "Average Deal Size", - "value", "$" + avgDealSize.toPlainString() - )); - rows.add(MapBuilder.of( - "metric", "Closed Won Deals", - "value", String.valueOf(count) - )); - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(10) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetProducer.java deleted file mode 100644 index 1302d4b..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Average Deal Size widget. - ** - ** Shows the average amount of closed-won deals. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_avg_deal_size widget (TABLE type). - *******************************************************************************/ -public class CrmAvgDealSizeWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_avg_deal_size"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(3) - .withIsCard(true) - .withLabel("Average Deal Size") - .withTooltip("Average amount of closed-won deals") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("bar_chart")) - .withCodeReference(new QCodeReference(CrmAvgDealSizeRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java index bc8490c..36cbbeb 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java @@ -19,109 +19,60 @@ */ -/******************************************************************************* - ** Renderer for the CRM Contact Timeline widget. - ** - ** Queries activities for a specific contactId (from widget input) and returns - ** a StepperData with each activity as a chronological step. Activities are - ** ordered by createDate ascending. - *******************************************************************************/ - package com.kingsrook.qbits.crm.widgets; import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; -import com.kingsrook.qbits.crm.activities.model.Activity; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; 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.StepperData; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.StringUtils; -import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* - ** Renders a contact's activity timeline as a stepper widget. + ** A contact's latest fifty recorded events across activities, sent messages, + ** web leads and consent. Each source is bounded before merging by create date. *******************************************************************************/ public class CrmContactTimelineRenderer extends AbstractWidgetRenderer { - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter - .ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()); - - - /******************************************************************************* - ** + ** Record sections supply id; contactId remains useful for embedded hosts. *******************************************************************************/ @Override public RenderWidgetOutput render(RenderWidgetInput input) throws QException { - String contactIdParam = input.getQueryParams().get("contactId"); - - if(!StringUtils.hasContent(contactIdParam)) + List> rows = new ArrayList<>(); + String id = input.getQueryParams().getOrDefault("id", input.getQueryParams().get("contactId")); + if(id != null && !id.isBlank() && GetAction.execute("crm_contact", Integer.valueOf(id)) != null) { - return (new RenderWidgetOutput(new StepperData("Contact Timeline", 0, List.of()))); - } - - Integer contactId = ValueUtils.getValueAsInteger(contactIdParam); - - ////////////////////////////////////////////////////////////// - // query activities for this contact, ordered chronologically // - ////////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(Activity.TABLE_NAME); - queryInput.setFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)) - .withOrderBy(new QFilterOrderBy("createDate", true))); - - QueryOutput queryOutput = new QueryAction().execute(queryInput); - - //////////////////////////////////// - // build steps from activities // - //////////////////////////////////// - List steps = new ArrayList<>(); - for(QRecord record : queryOutput.getRecords()) - { - String subject = record.getValueString("subject"); - if(subject == null) + List events = new ArrayList<>(); + for(String table : List.of("crm_activity", "crm_email_message", "crm_form_submission", "crm_consent_record")) { - subject = "Activity"; + for(QRecord record : CrmHomeRenderer.query(table, CrmHomeRenderer.equalsFilter("contactId", Integer.valueOf(id)), "createDate", false, 50)) + { + record.setValue("timelineTable", table); + events.add(record); + } + } + events.sort(Comparator.comparing((QRecord record) -> Objects.requireNonNullElse(record.getValueInstant("createDate"), Instant.EPOCH)).reversed() + .thenComparing(record -> record.getValueString("timelineTable")).thenComparing(record -> record.getValueInteger("id"))); + for(QRecord record : events.stream().limit(50).toList()) + { + String table = record.getValueString("timelineTable"); + String kind = Map.of("crm_activity", "Activity", "crm_email_message", "Email message", + "crm_form_submission", "Web lead", "crm_consent_record", "Consent record").get(table); + String detail = Objects.toString(record.getValue("subject"), Objects.toString(record.getValue("email"), kind)); + rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), record.getValue("createDate"), kind, detail)); } - - Instant createDate = record.getValueInstant("createDate"); - String dateStr = createDate != null ? DATE_FORMATTER.format(createDate) : ""; - - String description = record.getValueString("description"); - String linkText = StringUtils.hasContent(description) - ? description.substring(0, Math.min(description.length(), 100)) - : dateStr; - - steps.add(new StepperData.Step() - .withLabel(subject) - .withLinkText(linkText)); } - - ////////////////////////////////////////////////////////////// - // set activeStep to the last step (most recent activity) // - ////////////////////////////////////////////////////////////// - Integer activeStep = steps.isEmpty() ? 0 : steps.size() - 1; - - StepperData stepperData = new StepperData("Contact Timeline", activeStep, steps); - - return (new RenderWidgetOutput(stepperData)); + return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details"))); } - } diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetProducer.java index efae2d1..7ba4129 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetProducer.java @@ -22,13 +22,14 @@ /******************************************************************************* ** MetaData producer for the CRM Contact Timeline widget. ** - ** Provides a STEPPER view showing a chronological timeline of activities for - ** a specific contact. The contactId is passed as a widget input parameter. + ** Provides a standard table of activities, messages, web leads and consent for + ** the contact selected by the record screen id parameter. *******************************************************************************/ package com.kingsrook.qbits.crm.widgets; +import com.kingsrook.qbits.crm.security.CrmPermissions; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; @@ -39,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; /******************************************************************************* - ** Produces metadata for the crm_contact_timeline widget (STEPPER type). + ** Produces metadata for the crm_contact_timeline widget (TABLE type). *******************************************************************************/ public class CrmContactTimelineWidgetProducer extends MetaDataProducer { @@ -55,8 +56,9 @@ public class CrmContactTimelineWidgetProducer extends MetaDataProducer closedStageIds = findClosedStageIds(); - - /////////////////////////////////////////////////////////////// - // query all deals that are NOT in closed stages // - /////////////////////////////////////////////////////////////// - QQueryFilter filter = new QQueryFilter(); - if(!closedStageIds.isEmpty()) - { - filter.withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.NOT_IN, new ArrayList<>(closedStageIds))); - } - - QueryOutput dealOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME).withFilter(filter)); - - ////////////////////////////////////////////////////////// - // aggregate deals by expected close month // - ////////////////////////////////////////////////////////// - Map countByMonth = new TreeMap<>(); - Map amountByMonth = new TreeMap<>(); - Map weightedByMonth = new TreeMap<>(); - - for(QRecord record : dealOutput.getRecords()) - { - LocalDate expectedCloseDate = record.getValueLocalDate("expectedCloseDate"); - if(expectedCloseDate == null) - { - continue; - } - - YearMonth yearMonth = YearMonth.from(expectedCloseDate); - - countByMonth.merge(yearMonth, 1, Integer::sum); - - BigDecimal amount = record.getValueBigDecimal("amount"); - if(amount != null) - { - amountByMonth.merge(yearMonth, amount, BigDecimal::add); - } - - BigDecimal weighted = record.getValueBigDecimal("weightedAmount"); - if(weighted != null) - { - weightedByMonth.merge(yearMonth, weighted, BigDecimal::add); - } - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Month", "month", "1fr", null), - new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"), - new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"), - new TableData.Column("html", "Weighted Amount", "totalWeightedAmount", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(YearMonth yearMonth : countByMonth.keySet()) - { - rows.add(MapBuilder.of( - "month", yearMonth.format(MONTH_FORMAT), - "dealCount", countByMonth.getOrDefault(yearMonth, 0), - "totalAmount", amountByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString(), - "totalWeightedAmount", weightedByMonth.getOrDefault(yearMonth, BigDecimal.ZERO).toPlainString() - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(50) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - - - - /*************************************************************************** - ** Find all pipeline stage IDs that are closed (won or lost). - ***************************************************************************/ - private Set findClosedStageIds() throws QException - { - QQueryFilter filter = new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true)); - - QQueryFilter lostFilter = new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.EQUALS, true)); - - /////////////////////////////////////////////////////////////////////////// - // query stages that are closed won OR closed lost // - /////////////////////////////////////////////////////////////////////////// - QueryOutput wonOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME).withFilter(filter)); - - QueryOutput lostOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME).withFilter(lostFilter)); - - Set closedIds = wonOutput.getRecords().stream() - .map(r -> r.getValueInteger("id")) - .collect(Collectors.toSet()); - - lostOutput.getRecords().stream() - .map(r -> r.getValueInteger("id")) - .forEach(closedIds::add); - - return (closedIds); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetProducer.java deleted file mode 100644 index dd2a25d..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetProducer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Deal Forecast widget. - ** - ** Provides a TABLE view showing open deals grouped by expected close month - ** with counts, total amounts, and weighted amounts. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_deal_forecast widget (TABLE type). - *******************************************************************************/ -public class CrmDealForecastWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_deal_forecast"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Deal Forecast") - .withTooltip("Open deals grouped by expected close month") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("trending_up")) - .withCodeReference(new QCodeReference(CrmDealForecastRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmGoalsVisibility.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmGoalsVisibility.java new file mode 100644 index 0000000..b733bc5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmGoalsVisibility.java @@ -0,0 +1,72 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm.widgets; + + +import java.time.LocalDate; +import java.time.ZoneOffset; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; + + +/******************************************************************************* + ** Hide Goals in per-session frontend metadata unless a current goal is visible. + ** This is evaluated at request time, never while building shared app metadata. + *******************************************************************************/ +public class CrmGoalsVisibility implements CustomPermissionChecker +{ + /******************************************************************************* + ** Preserve crm.hasAccess and check existence through a bounded scoped query. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(CrmPermissions.APP_ACCESS)) + { + throw (new QPermissionDeniedException("CRM access is required.")); + } + LocalDate today = LocalDate.now(ZoneOffset.UTC); + QQueryFilter filter = new QQueryFilter() + .withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today)) + .withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today)); + try + { + if(!CrmHomeRenderer.query("crm_sales_goal", filter, "id", true, 1).isEmpty()) + { + return; + } + } + catch(QException e) + { + throw (new QPermissionDeniedException("Current goals are not available.", e)); + } + throw (new QPermissionDeniedException("No goals for the current period.")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java new file mode 100644 index 0000000..95c7d71 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java @@ -0,0 +1,496 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm.widgets; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmFormType; +import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils; +import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +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.AggregateResult; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +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.ParentWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; + + +/******************************************************************************* + ** Daily CRM views. Core actions retain the current session and record locks. + ** Monetary totals use base currency; weighted amounts are grouped by currency + ** before conversion. No query loads all deals, contacts or submissions. + *******************************************************************************/ +public class CrmHomeRenderer extends AbstractWidgetRenderer +{ + private static final String DEAL = "crm_deal"; + private static final String STAGE = "crm_pipeline_stage"; + private static final Aggregate COUNT = new Aggregate("id", AggregateOperator.COUNT); + private static final Aggregate AMOUNT = new Aggregate("amountInBaseCurrency", AggregateOperator.SUM); + private static final Aggregate WEIGHTED = new Aggregate("weightedAmount", AggregateOperator.SUM); + private static final GroupBy CURRENCY = new GroupBy(QFieldType.STRING, "currencyCode"); + + + /******************************************************************************* + ** Render only the requested home component using UTC reporting boundaries. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + switch(input.getWidgetMetaData().getName()) + { + case "crm_today": + return (new RenderWidgetOutput(new ParentWidgetData().withChildWidgetNameList(CrmHomeWidgets.TODAY).withLabel("Today"))); + case "crm_open_pipeline": + return (new RenderWidgetOutput(statistic(sum(DEAL, dealStatus(false), AMOUNT), true))); + case "crm_won_month": + return (new RenderWidgetOutput(statistic(sum(DEAL, period(dealStatus(true), "actualCloseDate", today.withDayOfMonth(1), today.plusDays(1)), AMOUNT), true))); + case "crm_new_contacts": + return (new RenderWidgetOutput(statistic(countSince("crm_contact", 30), false))); + case "crm_new_web_leads": + return (new RenderWidgetOutput(statistic(countSince("crm_form_submission", 7), false))); + case "crm_overdue_activities": + return (new RenderWidgetOutput(overdue())); + case "crm_pipeline_summary": + return (new RenderWidgetOutput(pipeline(input))); + case "crm_deal_forecast": + return (new RenderWidgetOutput(forecast(today))); + case "crm_rotting_deals": + return (new RenderWidgetOutput(rotting())); + case "crm_recent_web_leads": + return (new RenderWidgetOutput(webLeads())); + case "crm_goals": + return (new RenderWidgetOutput(goals(today))); + case "crm_sales_leaderboard": + return (new RenderWidgetOutput(leaderboard(today))); + default: + throw new QException("Unknown CRM home widget: " + input.getWidgetMetaData().getName()); + } + } + + + /******************************************************************************* + ** Preserve decimals and large counts in the standard statistics renderer. + *******************************************************************************/ + private StatisticsData statistic(BigDecimal value, boolean currency) + { + return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null)); + } + + + /******************************************************************************* + ** Rolling counts include the present instant and exclude future records. + *******************************************************************************/ + private BigDecimal countSince(String table, int days) throws QException + { + Instant now = Instant.now(); + return (sum(table, period(new QQueryFilter(), "createDate", now.minus(days, ChronoUnit.DAYS), now), COUNT)); + } + + + /******************************************************************************* + ** An unassigned activity is never treated as belonging to an anonymous user. + *******************************************************************************/ + private StatisticsData overdue() throws QException + { + String user = QContext.getQSession().getIdReference(); + if(user == null || user.isBlank()) + { + return (statistic(BigDecimal.ZERO, false)); + } + QQueryFilter filter = equalsFilter("assignedToUserId", user) + .withCriteria(new QFilterCriteria("isCompleted", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true)) + .withCriteria(new QFilterCriteria("dueDate", QCriteriaOperator.LESS_THAN, Instant.now())); + return (statistic(sum("crm_activity", filter, COUNT), false)); + } + + + /******************************************************************************* + ** Open and won stages are resolved through the current session as well. + *******************************************************************************/ + private QQueryFilter dealStatus(boolean won) throws QException + { + QQueryFilter filter = won ? equalsFilter("isClosedWon", true) : new QQueryFilter() + .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true)) + .withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true)); + List ids = allStages(filter).stream().map(row -> row.getValueInteger("id")).toList(); + return (new QQueryFilter().withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, ids))); + } + + + /******************************************************************************* + ** Stage dictionaries are paged; transactional facts are aggregated in SQL. + *******************************************************************************/ + private List allStages(QQueryFilter filter) throws QException + { + return (dictionary(STAGE, filter)); + } + + + /******************************************************************************* + ** Read small settings dictionaries in deterministic keyset pages. + *******************************************************************************/ + private List dictionary(String table, QQueryFilter filter) throws QException + { + List stages = new ArrayList<>(); + int lastId = 0; + while(true) + { + QQueryFilter page = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)); + List records = query(table, page, "id", true, 200); + stages.addAll(records); + if(records.size() < 200) + { + return (stages); + } + lastId = records.getLast().getValueInteger("id"); + } + } + + + /******************************************************************************* + ** Stage and deal count share one column, keeping both monetary columns. + *******************************************************************************/ + private TableData pipeline(RenderWidgetInput input) throws QException + { + List pipelines = dictionary("crm_pipeline", equalsFilter("isActive", true)); + List> rows = new ArrayList<>(); + String selected = pipelines.isEmpty() ? null : pipelines.getFirst().getValueString("id"); + String requested = input.getQueryParams().get("pipelineId"); + if(pipelines.stream().anyMatch(pipeline -> pipeline.getValueString("id").equals(requested))) + { + selected = requested; + } + if(!pipelines.isEmpty()) + { + Integer pipelineId = Integer.valueOf(selected); + List stages = allStages(equalsFilter("pipelineId", pipelineId)); + stages.sort(Comparator.comparing(row -> Objects.requireNonNullElse(row.getValueInteger("sortOrder"), 0))); + GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, "pipelineStageId"); + Map totals = groupedTotals(equalsFilter("pipelineId", pipelineId), stageGroup); + for(QRecord stage : stages) + { + BigDecimal[] values = totals.getOrDefault(stage.getValue("id"), new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}); + String href = AiLinkBuilder.recordHref(STAGE, stage.getValue("id")); + rows.add(row(href, stage.getValueString("name") + " (" + values[0].toPlainString() + " deals)", values[1], values[2])); + } + } + TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)"); + if(pipelines.size() > 1) + { + data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline")) + .withDropdownDefaultValueList(List.of(selected)) + .withDropdownDataList(List.of(pipelines.stream().map(pipeline -> Map.of("id", pipeline.getValueString("id"), + "label", Objects.toString(pipeline.getValue("name"), "Pipeline " + pipeline.getValueString("id")))).toList())); + } + return (data); + } + + + /******************************************************************************* + ** Six calendar buckets, including the current month, with empty months kept. + *******************************************************************************/ + private TableData forecast(LocalDate today) throws QException + { + List> rows = new ArrayList<>(); + for(int month = 0; month < 6; month++) + { + LocalDate start = today.withDayOfMonth(1).plusMonths(month); + QQueryFilter filter = period(dealStatus(false), "expectedCloseDate", start, start.plusMonths(1)); + BigDecimal[] totals = totals(filter); + rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0], totals[2])); + } + return (table(rows, "Month", "Deals", "Weighted amount (base)")); + } + + + /******************************************************************************* + ** Limit the candidates in each stage in SQL, then select the oldest ten. + *******************************************************************************/ + private TableData rotting() throws QException + { + List candidates = new ArrayList<>(); + Map stages = new LinkedHashMap<>(); + Instant now = Instant.now(); + QQueryFilter stageFilter = new QQueryFilter() + .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true)) + .withCriteria(new QFilterCriteria("isClosedLost", QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, true)) + .withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.GREATER_THAN, 0)); + for(QRecord stage : allStages(stageFilter)) + { + Integer id = stage.getValueInteger("id"); + stages.put(id, stage.getValueString("name")); + QQueryFilter filter = equalsFilter("pipelineStageId", id) + .withCriteria(new QFilterCriteria("stageEnteredDate", QCriteriaOperator.LESS_THAN, now.minus(stage.getValueInteger("rotDays"), ChronoUnit.DAYS))); + candidates.addAll(query(DEAL, filter, "stageEnteredDate", true, 10)); + } + candidates.sort(Comparator.comparing(row -> row.getValueInstant("stageEnteredDate"))); + List> rows = new ArrayList<>(); + for(QRecord deal : candidates.stream().limit(10).toList()) + { + rows.add(row(AiLinkBuilder.recordHref(DEAL, deal.getValue("id")), deal.getValueString("name"), + stages.get(deal.getValueInteger("pipelineStageId")), ChronoUnit.DAYS.between(deal.getValueInstant("stageEnteredDate"), now))); + } + return (table(rows, "Deal", "Stage", "Days in stage")); + } + + + /******************************************************************************* + ** Latest ten submissions, with escaped labels and links on every cell. + *******************************************************************************/ + private TableData webLeads() throws QException + { + List> rows = new ArrayList<>(); + for(QRecord lead : query("crm_form_submission", new QQueryFilter(), "createDate", false, 10)) + { + String name = (Objects.toString(lead.getValue("firstName"), "") + " " + Objects.toString(lead.getValue("lastName"), "")).trim(); + if(name.isBlank()) + { + name = Objects.toString(lead.getValue("email"), "Web lead"); + } + String form = "Unknown"; + for(CrmFormType value : CrmFormType.values()) + { + if(value.getId().equals(lead.getValueInteger("formType"))) + { + form = value.getLabel(); + } + } + String status = "New"; + for(CrmFormConversionStatus value : CrmFormConversionStatus.values()) + { + if(value.getId().equals(lead.getValueInteger("conversionStatus"))) + { + status = value.getLabel(); + } + } + rows.add(row(AiLinkBuilder.recordHref("crm_form_submission", lead.getValue("id")), name, form, status)); + } + return (table(rows, "Name or email", "Form", "Status")); + } + + + /******************************************************************************* + ** Current goals only; metadata visibility hides the widget when none exist. + *******************************************************************************/ + private TableData goals(LocalDate today) throws QException + { + QQueryFilter filter = new QQueryFilter() + .withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today)) + .withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today)); + List> rows = new ArrayList<>(); + for(QRecord goal : query("crm_sales_goal", filter, "id", true, 50)) + { + QQueryFilter deals = period(dealStatus(true), "actualCloseDate", goal.getValueLocalDate("periodStart"), goal.getValueLocalDate("periodEnd").plusDays(1)); + deals.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, goal.getValue("userId"))); + if(goal.getValue("pipelineId") != null) + { + deals.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getValue("pipelineId"))); + } + BigDecimal actual = sum(DEAL, deals, AMOUNT); + BigDecimal target = CrmCurrencyUtils.convertToBaseCurrency(goal.getValueBigDecimal("targetAmount"), goal.getValueString("currencyCode")); + String attainment = target != null && target.signum() > 0 ? actual.multiply(BigDecimal.valueOf(100)).divide(target, 1, RoundingMode.HALF_UP) + "%" : "No target"; + rows.add(row(AiLinkBuilder.recordHref("crm_sales_goal", goal.getValue("id")), + goal.getValueString("userId") + " (" + attainment + ")", target, actual)); + } + TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)"); + return (data); + } + + + /******************************************************************************* + ** Quarter-to-date won revenue grouped by owner, linking to matching deals. + *******************************************************************************/ + private TableData leaderboard(LocalDate today) throws QException + { + LocalDate start = today.withMonth(((today.getMonthValue() - 1) / 3) * 3 + 1).withDayOfMonth(1); + QQueryFilter filter = period(dealStatus(true), "actualCloseDate", start, today.plusDays(1)); + GroupBy owner = new GroupBy(QFieldType.STRING, "ownerUserId"); + List results = aggregate(DEAL, filter, List.of(COUNT, AMOUNT), List.of(owner)); + results.sort(Comparator.comparing((AggregateResult result) -> number(result.getAggregateValue(AMOUNT))).reversed()); + List> rows = new ArrayList<>(); + for(AggregateResult result : results) + { + Serializable user = result.getGroupByValue(owner); + QQueryFilter ownerFilter = filter.clone().withCriteria(new QFilterCriteria("ownerUserId", user == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, user)); + rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, ownerFilter), Objects.toString(user, "Unassigned"), + number(result.getAggregateValue(COUNT)), number(result.getAggregateValue(AMOUNT)))); + } + return (table(rows, "Owner", "Won deals", "Won amount (base)")); + } + + + /******************************************************************************* + ** Aggregate currency groups instead of loading the underlying deals. + *******************************************************************************/ + private Map groupedTotals(QQueryFilter filter, GroupBy group) throws QException + { + List groups = new ArrayList<>(List.of(CURRENCY)); + if(group != null) + { + groups.add(group); + } + Map totals = new LinkedHashMap<>(); + for(AggregateResult result : aggregate(DEAL, filter, List.of(COUNT, AMOUNT, WEIGHTED), groups)) + { + Serializable key = group == null ? "total" : result.getGroupByValue(group); + BigDecimal[] values = totals.computeIfAbsent(key, ignored -> new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}); + values[0] = values[0].add(number(result.getAggregateValue(COUNT))); + values[1] = values[1].add(number(result.getAggregateValue(AMOUNT))); + values[2] = values[2].add(CrmCurrencyUtils.convertToBaseCurrency(number(result.getAggregateValue(WEIGHTED)), (String) result.getGroupByValue(CURRENCY))); + } + return (totals); + } + + + /******************************************************************************* + ** Totals across all currency buckets. + *******************************************************************************/ + private BigDecimal[] totals(QQueryFilter filter) throws QException + { + return (groupedTotals(filter, null).getOrDefault("total", new BigDecimal[] {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO})); + } + + + /******************************************************************************* + ** A scalar aggregate with an explicit zero for an empty selection. + *******************************************************************************/ + private BigDecimal sum(String table, QQueryFilter filter, Aggregate aggregate) throws QException + { + List results = aggregate(table, filter, List.of(aggregate), List.of()); + return (results.isEmpty() ? BigDecimal.ZERO : number(results.getFirst().getAggregateValue(aggregate))); + } + + + /******************************************************************************* + ** Delegate all permission and record-lock filtering to the core action. + *******************************************************************************/ + private List aggregate(String table, QQueryFilter filter, List aggregates, List groups) throws QException + { + return (new AggregateAction().execute(new AggregateInput(table).withFilter(filter).withAggregates(aggregates).withGroupBys(groups)).getResults()); + } + + + /******************************************************************************* + ** Normalize aggregate values without lossy integer conversions. + *******************************************************************************/ + private BigDecimal number(Serializable value) + { + return (value == null ? BigDecimal.ZERO : new BigDecimal(value.toString())); + } + + + /******************************************************************************* + ** A bounded query with deterministic ordering for tied timestamps. + *******************************************************************************/ + static List query(String table, QQueryFilter filter, String order, boolean ascending, int limit) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter.withOrderBy(new QFilterOrderBy(order, ascending)) + .withOrderBy(new QFilterOrderBy("id", ascending)).withLimit(limit))).getRecords()); + } + + + /******************************************************************************* + ** Equality filter. + *******************************************************************************/ + static QQueryFilter equalsFilter(String field, Serializable value) + { + return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value))); + } + + + /******************************************************************************* + ** Half-open reporting interval, avoiding end-of-month time rounding. + *******************************************************************************/ + private QQueryFilter period(QQueryFilter filter, String field, Serializable start, Serializable end) + { + return (filter.withCriteria(new QFilterCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, start)) + .withCriteria(new QFilterCriteria(field, QCriteriaOperator.LESS_THAN, end))); + } + + + /******************************************************************************* + ** Every cell navigates to its record, or to the records in an aggregate. + *******************************************************************************/ + static Map row(String href, Object first, Object second, Object third) + { + return (Map.of("first", link(href, first), "second", link(href, second), "third", link(href, third))); + } + + + /******************************************************************************* + ** Standard table cells may contain links; user text is always escaped. + *******************************************************************************/ + private static String link(String href, Object label) + { + return ("" + escape(Objects.toString(label, "")) + ""); + } + + + /******************************************************************************* + ** Escape text and attributes in links. + *******************************************************************************/ + private static String escape(String value) + { + return (value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); + } + + + /******************************************************************************* + ** Three columns, full-width metadata, and the standard paginated table. + *******************************************************************************/ + static TableData table(List> rows, String first, String second, String third) + { + return (new TableData(null, List.of(new TableData.Column("html", first, "first", "2fr", "left"), + new TableData.Column("html", second, "second", "1fr", "left"), new TableData.Column("html", third, "third", "1fr", "left")), rows) + .withRowsPerPage(10).withHidePaginationDropdown(true)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java new file mode 100644 index 0000000..5c8d5b6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm.widgets; + + +import java.util.List; +import com.kingsrook.qbits.crm.security.CrmPermissions; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; + + +/******************************************************************************* + ** Standard widgets for the single CRM home. Today holds five statistics. + *******************************************************************************/ +public class CrmHomeWidgets extends MetaDataProducer +{ + public static final List HOME = List.of("crm_today", "crm_pipeline_summary", "crm_deal_forecast", "crm_rotting_deals", + "crm_recent_web_leads", "crm_goals", "crm_sales_leaderboard"); + public static final List TODAY = List.of("crm_open_pipeline", "crm_won_month", "crm_new_contacts", "crm_new_web_leads", "crm_overdue_activities"); + + + /******************************************************************************* + ** All metrics and tables use the same session-aware data renderer. + *******************************************************************************/ + @Override + public MetaDataProducerMultiOutput produce(QInstance instance) + { + MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + List labels = List.of("Today", "Pipeline by stage", "Forecast", "Needs attention", "Recent web leads", "Goals", "Leaderboard"); + for(int i = 0; i < HOME.size(); i++) + { + output.add(widget(HOME.get(i), labels.get(i), i == 0 ? WidgetType.PARENT_WIDGET : WidgetType.TABLE)); + } + List statistics = List.of("Open pipeline (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities"); + for(int i = 0; i < TODAY.size(); i++) + { + output.add(widget(TODAY.get(i), statistics.get(i), WidgetType.STATISTICS)); + } + return (output); + } + + + /******************************************************************************* + ** A full-width, permission-protected standard widget. + *******************************************************************************/ + private QWidgetMetaData widget(String name, String label, WidgetType type) + { + QWidgetMetaData widget = new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()).withGridColumns(12) + .withIsCard(type != WidgetType.PARENT_WIDGET).withShowReloadButton(true) + .withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE)) + .withCodeReference(new QCodeReference(CrmHomeRenderer.class)); + if("crm_goals".equals(name)) + { + widget.getPermissionRules().withCustomPermissionChecker(new QCodeReference(CrmGoalsVisibility.class)); + } + return (widget); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionRenderer.java deleted file mode 100644 index f9ab5fe..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionRenderer.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Pipeline Conversion widget. - ** - ** Queries DealStageHistory, counts transitions into each stage (entered) - ** versus transitions out (progressed), and computes a conversion rate. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.DealStageHistory; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders pipeline conversion data as a table widget. - *******************************************************************************/ -public class CrmPipelineConversionRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // query all deal stage history // - /////////////////////////////////////////////////////////// - QueryOutput historyOutput = new QueryAction().execute( - new QueryInput(DealStageHistory.TABLE_NAME)); - - /////////////////////////////////////////////////////////// - // count entered and progressed for each toStageId // - /////////////////////////////////////////////////////////// - Map enteredByStage = new HashMap<>(); - Map progressedByStage = new HashMap<>(); - - for(QRecord record : historyOutput.getRecords()) - { - Integer toStageId = record.getValueInteger("toStageId"); - Integer fromStageId = record.getValueInteger("fromStageId"); - - if(toStageId != null) - { - enteredByStage.merge(toStageId, 1, Integer::sum); - } - if(fromStageId != null) - { - progressedByStage.merge(fromStageId, 1, Integer::sum); - } - } - - /////////////////////////////////////////////////////////// - // build stage name lookup // - /////////////////////////////////////////////////////////// - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME)); - - Map stageNames = new HashMap<>(); - for(QRecord record : stageOutput.getRecords()) - { - stageNames.put(record.getValueInteger("id"), record.getValueString("name")); - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Stage", "stageName", "2fr", null), - new TableData.Column("html", "Entered", "entered", "1fr", "right"), - new TableData.Column("html", "Progressed", "progressed", "1fr", "right"), - new TableData.Column("html", "Conversion %", "conversionRate", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(Map.Entry entry : enteredByStage.entrySet()) - { - Integer stageId = entry.getKey(); - int entered = entry.getValue(); - int progressed = progressedByStage.getOrDefault(stageId, 0); - - BigDecimal conversionRate = entered > 0 - ? BigDecimal.valueOf(progressed).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(entered), 1, RoundingMode.HALF_UP) - : BigDecimal.ZERO; - - String stageName = stageNames.getOrDefault(stageId, "Stage " + stageId); - - rows.add(MapBuilder.of( - "stageName", stageName, - "entered", entered, - "progressed", progressed, - "conversionRate", conversionRate + "%" - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(25) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionWidgetProducer.java deleted file mode 100644 index d12b11e..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineConversionWidgetProducer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Pipeline Conversion widget. - ** - ** Shows conversion rates between pipeline stages based on DealStageHistory - ** transition data. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_pipeline_conversion widget (TABLE type). - *******************************************************************************/ -public class CrmPipelineConversionWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_pipeline_conversion"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Pipeline Conversion") - .withTooltip("Stage transition conversion rates") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("funnel_chart")) - .withCodeReference(new QCodeReference(CrmPipelineConversionRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryRenderer.java deleted file mode 100644 index 6da9503..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryRenderer.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Pipeline Summary widget. - ** - ** Queries deals grouped by pipelineStageId for a given pipelineId and returns - ** a table with columns: stageName, dealCount, totalAmount, weightedAmount. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders the pipeline summary as a table widget showing deal counts and - ** amounts grouped by pipeline stage. - *******************************************************************************/ -public class CrmPipelineSummaryRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////////// - // get pipelineId from widget input query params // - /////////////////////////////////////////////////////////////// - Integer pipelineId = null; - if(input.getQueryParams() != null && input.getQueryParams().containsKey("pipelineId")) - { - pipelineId = Integer.valueOf(input.getQueryParams().get("pipelineId")); - } - - /////////////////////////////////////////////////////////////// - // query all stages for the pipeline, ordered by sortOrder // - /////////////////////////////////////////////////////////////// - QQueryFilter stageFilter = new QQueryFilter() - .withOrderBy(new QFilterOrderBy("sortOrder", true)); - if(pipelineId != null) - { - stageFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId)); - } - - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME).withFilter(stageFilter)); - - ////////////////////////////////////////////////////////// - // build a map of stageId -> stageName for display // - ////////////////////////////////////////////////////////// - Map stageNames = new LinkedHashMap<>(); - for(QRecord stageRecord : stageOutput.getRecords()) - { - stageNames.put(stageRecord.getValueInteger("id"), stageRecord.getValueString("name")); - } - - /////////////////////////////////////////////////////////////// - // query deals for the pipeline // - /////////////////////////////////////////////////////////////// - QQueryFilter dealFilter = new QQueryFilter(); - if(pipelineId != null) - { - dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, pipelineId)); - } - - QueryOutput dealOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter)); - - ////////////////////////////////////////////////////////// - // aggregate deals by stage // - ////////////////////////////////////////////////////////// - Map countByStage = new LinkedHashMap<>(); - Map totalAmountByStage = new LinkedHashMap<>(); - Map weightedByStage = new LinkedHashMap<>(); - - for(Integer stageId : stageNames.keySet()) - { - countByStage.put(stageId, 0); - totalAmountByStage.put(stageId, BigDecimal.ZERO); - weightedByStage.put(stageId, BigDecimal.ZERO); - } - - for(QRecord dealRecord : dealOutput.getRecords()) - { - Integer stageId = dealRecord.getValueInteger("pipelineStageId"); - if(stageId == null || !stageNames.containsKey(stageId)) - { - continue; - } - - countByStage.merge(stageId, 1, Integer::sum); - - BigDecimal amount = dealRecord.getValueBigDecimal("amount"); - if(amount != null) - { - totalAmountByStage.merge(stageId, amount, BigDecimal::add); - } - - BigDecimal weighted = dealRecord.getValueBigDecimal("weightedAmount"); - if(weighted != null) - { - weightedByStage.merge(stageId, weighted, BigDecimal::add); - } - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Stage", "stageName", "2fr", null), - new TableData.Column("html", "Deal Count", "dealCount", "1fr", "right"), - new TableData.Column("html", "Total Amount", "totalAmount", "1fr", "right"), - new TableData.Column("html", "Weighted Amount", "weightedAmount", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(Integer stageId : stageNames.keySet()) - { - rows.add(MapBuilder.of( - "stageName", stageNames.get(stageId), - "dealCount", countByStage.getOrDefault(stageId, 0), - "totalAmount", totalAmountByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString(), - "weightedAmount", weightedByStage.getOrDefault(stageId, BigDecimal.ZERO).toPlainString() - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(50) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryWidgetProducer.java deleted file mode 100644 index 8dc0349..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmPipelineSummaryWidgetProducer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Pipeline Summary widget. - ** - ** Provides a TABLE view showing deal counts and amounts grouped by pipeline - ** stage for a given pipeline. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_pipeline_summary widget (TABLE type). - *******************************************************************************/ -public class CrmPipelineSummaryWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_pipeline_summary"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Pipeline Summary") - .withTooltip("Deal counts and amounts by pipeline stage") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("linear_scale")) - .withCodeReference(new QCodeReference(CrmPipelineSummaryRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentRenderer.java deleted file mode 100644 index 487110d..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentRenderer.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Quota Attainment widget. - ** - ** Queries the SalesGoal for the current user and current period, then queries - ** won deals in that period to compute attainment percentage. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qbits.crm.scoring.model.SalesGoal; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.MultiStatisticsData; -import com.kingsrook.qqq.backend.core.model.data.QRecord; - - -/******************************************************************************* - ** Renders quota attainment as multi-statistics showing target, actual, and - ** attainment percentage. - *******************************************************************************/ -public class CrmQuotaAttainmentRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - String currentUserId = QContext.getQSession() != null - ? QContext.getQSession().getIdReference() : null; - - LocalDate today = LocalDate.now(); - - /////////////////////////////////////////////// - // query SalesGoal for current user/period // - /////////////////////////////////////////////// - QueryOutput goalQuery = new QueryAction().execute( - new QueryInput(SalesGoal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, currentUserId)) - .withCriteria(new QFilterCriteria("periodStart", QCriteriaOperator.LESS_THAN_OR_EQUALS, today)) - .withCriteria(new QFilterCriteria("periodEnd", QCriteriaOperator.GREATER_THAN_OR_EQUALS, today)))); - - List groups = new ArrayList<>(); - - if(goalQuery.getRecords().isEmpty()) - { - /////////////////////////////////////////////// - // no goal found -- show empty state // - /////////////////////////////////////////////// - groups.add(new MultiStatisticsData.StatisticsGroupData() - .withHeader("No Goal Set") - .withSubheader("current period") - .withStatisticList(List.of( - new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", 0, "%") - ))); - } - else - { - SalesGoal goal = new SalesGoal(goalQuery.getRecords().get(0)); - - /////////////////////////////////////////////// - // find closed-won stage ids // - /////////////////////////////////////////////// - QueryOutput stageQuery = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true)))); - - List closedWonStageIds = stageQuery.getRecords().stream() - .map(r -> r.getValueInteger("id")) - .toList(); - - /////////////////////////////////////////////// - // query won deals in the period // - /////////////////////////////////////////////// - BigDecimal totalWon = BigDecimal.ZERO; - - if(!closedWonStageIds.isEmpty()) - { - QQueryFilter dealFilter = new QQueryFilter() - .withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, currentUserId)) - .withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds)) - .withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, goal.getPeriodStart())) - .withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, goal.getPeriodEnd())); - - if(goal.getPipelineId() != null) - { - dealFilter.withCriteria(new QFilterCriteria("pipelineId", QCriteriaOperator.EQUALS, goal.getPipelineId())); - } - - QueryOutput dealQuery = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME).withFilter(dealFilter)); - - for(QRecord dealRecord : dealQuery.getRecords()) - { - BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency"); - if(amount != null) - { - totalWon = totalWon.add(amount); - } - } - } - - /////////////////////////////////////////////// - // compute attainment percentage // - /////////////////////////////////////////////// - int attainmentPct = 0; - if(goal.getTargetAmount() != null && goal.getTargetAmount().compareTo(BigDecimal.ZERO) > 0) - { - attainmentPct = totalWon - .multiply(new BigDecimal(100)) - .divide(goal.getTargetAmount(), 0, RoundingMode.HALF_UP) - .intValue(); - } - - groups.add(new MultiStatisticsData.StatisticsGroupData() - .withHeader("Quota Attainment") - .withSubheader(goal.getPeriodStart() + " to " + goal.getPeriodEnd()) - .withStatisticList(List.of( - new MultiStatisticsData.StatisticsGroupData.Statistic("Target", goal.getTargetAmount().intValue(), null), - new MultiStatisticsData.StatisticsGroupData.Statistic("Actual", totalWon.intValue(), null), - new MultiStatisticsData.StatisticsGroupData.Statistic("Attainment", attainmentPct, "%") - ))); - } - - MultiStatisticsData data = new MultiStatisticsData("Quota Attainment", groups); - - return (new RenderWidgetOutput(data)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentWidgetProducer.java deleted file mode 100644 index 0f820e7..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmQuotaAttainmentWidgetProducer.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Quota Attainment widget. - ** - ** Provides a MULTI_STATISTICS view showing quota attainment percentage for - ** the current user and current period. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; - - -/******************************************************************************* - ** Produces metadata for the crm_quota_attainment widget (MULTI_STATISTICS type). - *******************************************************************************/ -public class CrmQuotaAttainmentWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_quota_attainment"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.MULTI_STATISTICS.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Quota Attainment") - .withTooltip("Your quota attainment for the current period") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new QIcon("flag")) - .withCodeReference(new QCodeReference(CrmQuotaAttainmentRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsRenderer.java deleted file mode 100644 index 7728157..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsRenderer.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Recent Deals widget. - ** - ** Queries deals with actualCloseDate set, ordered by actualCloseDate DESC, - ** limited to 20 rows. Returns table with name, amount, owner, closedDate, - ** winLossReason. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders recent deals as a table widget. - *******************************************************************************/ -public class CrmRecentDealsRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // query recent deals with actualCloseDate set // - /////////////////////////////////////////////////////////// - QueryOutput queryOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK)) - .withOrderBy(new QFilterOrderBy("actualCloseDate", false)) - .withLimit(20))); - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Name", "name", "2fr", null), - new TableData.Column("html", "Amount", "amount", "1fr", "right"), - new TableData.Column("html", "Owner", "ownerUserId", "1fr", null), - new TableData.Column("html", "Closed Date", "closedDate", "1fr", "right"), - new TableData.Column("html", "Win/Loss Reason", "winLossReason", "1fr", null) - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(QRecord record : queryOutput.getRecords()) - { - String name = record.getValueString("name"); - if(name == null) - { - name = ""; - } - - BigDecimal amount = record.getValueBigDecimal("amount"); - String amountDisplay = amount != null ? "$" + amount.toPlainString() : "-"; - - String ownerUserId = record.getValueString("ownerUserId"); - if(ownerUserId == null) - { - ownerUserId = "-"; - } - - LocalDate closedDate = record.getValueLocalDate("actualCloseDate"); - String closedDateDisplay = closedDate != null ? closedDate.toString() : "-"; - - Integer winLossReasonId = record.getValueInteger("winLossReasonId"); - String winLossDisplay = winLossReasonId != null ? String.valueOf(winLossReasonId) : "-"; - - rows.add(MapBuilder.of( - "name", name, - "amount", amountDisplay, - "ownerUserId", ownerUserId, - "closedDate", closedDateDisplay, - "winLossReason", winLossDisplay - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(20) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsWidgetProducer.java deleted file mode 100644 index 2dfff5a..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRecentDealsWidgetProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Recent Deals widget. - ** - ** Shows the most recently closed deals with key details. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_recent_deals widget (TABLE type). - *******************************************************************************/ -public class CrmRecentDealsWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_recent_deals"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Recent Deals") - .withTooltip("Most recently closed deals") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("receipt_long")) - .withCodeReference(new QCodeReference(CrmRecentDealsRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsRenderer.java deleted file mode 100644 index 541b156..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsRenderer.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Rotting Deals widget. - ** - ** Queries deals and their pipeline stages, finds deals where the number of - ** days since stageEnteredDate exceeds the stage's rotDays threshold. - ** Returns a table with columns: dealName, stageName, daysInStage, rotDays, - ** ownerUserId, amount. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders the rotting deals table showing deals past their stage rot threshold. - *******************************************************************************/ -public class CrmRottingDealsRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - Instant now = Instant.now(); - - /////////////////////////////////////////////////////////////// - // query all stages that have a rotDays value set // - /////////////////////////////////////////////////////////////// - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("rotDays", QCriteriaOperator.IS_NOT_BLANK)))); - - /////////////////////////////////////////////////////////////// - // build a map of stageId -> PipelineStage for lookup // - /////////////////////////////////////////////////////////////// - Map stageMap = new HashMap<>(); - for(QRecord stageRecord : stageOutput.getRecords()) - { - PipelineStage stage = new PipelineStage(stageRecord); - stageMap.put(stage.getId(), stage); - } - - if(stageMap.isEmpty()) - { - ////////////////////////////////////////////////////////// - // no stages with rot days -- return empty table // - ////////////////////////////////////////////////////////// - return (buildEmptyOutput()); - } - - /////////////////////////////////////////////////////////////// - // query deals in those stages // - /////////////////////////////////////////////////////////////// - QueryOutput dealOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, new ArrayList<>(stageMap.keySet()))))); - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Deal", "dealName", "2fr", null), - new TableData.Column("html", "Stage", "stageName", "1fr", null), - new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"), - new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"), - new TableData.Column("html", "Owner", "ownerUserId", "1fr", null), - new TableData.Column("html", "Amount", "amount", "1fr", "right") - ); - - ////////////////////////////////////////////////////////// - // filter to only deals past the rot threshold // - ////////////////////////////////////////////////////////// - List> rows = new ArrayList<>(); - for(QRecord dealRecord : dealOutput.getRecords()) - { - Integer stageId = dealRecord.getValueInteger("pipelineStageId"); - PipelineStage stage = stageMap.get(stageId); - - if(stage == null || stage.getRotDays() == null) - { - continue; - } - - Instant stageEnteredDate = dealRecord.getValueInstant("stageEnteredDate"); - if(stageEnteredDate == null) - { - continue; - } - - long daysInStage = ChronoUnit.DAYS.between(stageEnteredDate, now); - if(daysInStage > stage.getRotDays()) - { - String dealName = dealRecord.getValueString("name"); - String ownerUserId = dealRecord.getValueString("ownerUserId"); - BigDecimal amount = dealRecord.getValueBigDecimal("amount"); - - rows.add(MapBuilder.of( - "dealName", dealName != null ? dealName : "", - "stageName", stage.getName(), - "daysInStage", (int) daysInStage, - "rotDays", stage.getRotDays(), - "ownerUserId", ownerUserId != null ? ownerUserId : "", - "amount", amount != null ? amount.toPlainString() : "0" - )); - } - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(50) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - - - - /*************************************************************************** - ** Build an empty table output when there are no rotting deals. - ***************************************************************************/ - private RenderWidgetOutput buildEmptyOutput() - { - List columns = List.of( - new TableData.Column("html", "Deal", "dealName", "2fr", null), - new TableData.Column("html", "Stage", "stageName", "1fr", null), - new TableData.Column("html", "Days in Stage", "daysInStage", "1fr", "right"), - new TableData.Column("html", "Rot Days", "rotDays", "1fr", "right"), - new TableData.Column("html", "Owner", "ownerUserId", "1fr", null), - new TableData.Column("html", "Amount", "amount", "1fr", "right") - ); - - TableData tableData = new TableData(null, columns, new ArrayList<>()) - .withRowsPerPage(50) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsWidgetProducer.java deleted file mode 100644 index 99cb3ae..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmRottingDealsWidgetProducer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Rotting Deals widget. - ** - ** Provides a TABLE view showing deals that have exceeded their current - ** stage's rotDays threshold, indicating they need attention. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_rotting_deals widget (TABLE type). - *******************************************************************************/ -public class CrmRottingDealsWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_rotting_deals"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Rotting Deals") - .withTooltip("Deals that have exceeded their stage's rot days threshold") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("warning")) - .withCodeReference(new QCodeReference(CrmRottingDealsRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthRenderer.java deleted file mode 100644 index 52a56cc..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthRenderer.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Sales Cycle Length widget. - ** - ** Queries closedWon deals and computes the average number of days from - ** createDate to actualCloseDate. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders sales cycle length as a single-stat table widget. - *******************************************************************************/ -public class CrmSalesCycleLengthRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // find closedWon stage IDs // - /////////////////////////////////////////////////////////// - Set closedWonStageIds = new HashSet<>(); - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true)))); - - for(QRecord record : stageOutput.getRecords()) - { - closedWonStageIds.add(record.getValueInteger("id")); - } - - /////////////////////////////////////////////////////////// - // query deals with actualCloseDate set // - /////////////////////////////////////////////////////////// - QueryOutput dealOutput = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("actualCloseDate", QCriteriaOperator.IS_NOT_BLANK)))); - - /////////////////////////////////////////////////////////// - // compute average days for closedWon deals // - /////////////////////////////////////////////////////////// - long totalDays = 0; - int count = 0; - - for(QRecord record : dealOutput.getRecords()) - { - Integer stageId = record.getValueInteger("pipelineStageId"); - if(closedWonStageIds.contains(stageId)) - { - Instant createDate = record.getValueInstant("createDate"); - LocalDate actualCloseDate = record.getValueLocalDate("actualCloseDate"); - - if(createDate != null && actualCloseDate != null) - { - LocalDate createLocalDate = createDate.atZone(ZoneId.systemDefault()).toLocalDate(); - long days = ChronoUnit.DAYS.between(createLocalDate, actualCloseDate); - totalDays += days; - count++; - } - } - } - - BigDecimal avgCycleLength = count > 0 - ? BigDecimal.valueOf(totalDays).divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP) - : BigDecimal.ZERO; - - /////////////////////////// - // build table output // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Metric", "metric", "1fr", null), - new TableData.Column("html", "Value", "value", "1fr", "right") - ); - - List> rows = new ArrayList<>(); - rows.add(MapBuilder.of( - "metric", "Avg Sales Cycle (days)", - "value", avgCycleLength.toPlainString() - )); - rows.add(MapBuilder.of( - "metric", "Closed Won Deals", - "value", String.valueOf(count) - )); - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(10) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthWidgetProducer.java deleted file mode 100644 index 0da6b3f..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesCycleLengthWidgetProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Sales Cycle Length widget. - ** - ** Shows the average number of days from deal creation to close for won deals. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_sales_cycle_length widget (TABLE type). - *******************************************************************************/ -public class CrmSalesCycleLengthWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_sales_cycle_length"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(3) - .withIsCard(true) - .withLabel("Sales Cycle Length") - .withTooltip("Average days from creation to close for won deals") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("schedule")) - .withCodeReference(new QCodeReference(CrmSalesCycleLengthRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardRenderer.java deleted file mode 100644 index fe1eb92..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardRenderer.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the CRM Sales Leaderboard widget. - ** - ** Queries deals in closed-won stages, groups by ownerUserId, sums - ** amountInBaseCurrency, and returns a ranked table. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders the sales leaderboard as a table widget, ranking reps by closed-won - ** deal revenue. - *******************************************************************************/ -public class CrmSalesLeaderboardRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // find closed-won stage ids // - /////////////////////////////////////////////////////////// - QueryOutput stageQuery = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("isClosedWon", QCriteriaOperator.EQUALS, true)))); - - List closedWonStageIds = stageQuery.getRecords().stream() - .map(r -> r.getValueInteger("id")) - .toList(); - - /////////////////////////////////////////////////////////// - // query deals in closed-won stages // - /////////////////////////////////////////////////////////// - Map revenueByOwner = new LinkedHashMap<>(); - - if(!closedWonStageIds.isEmpty()) - { - QueryOutput dealQuery = new QueryAction().execute( - new QueryInput(Deal.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.IN, closedWonStageIds)))); - - for(QRecord dealRecord : dealQuery.getRecords()) - { - String ownerUserId = dealRecord.getValueString("ownerUserId"); - BigDecimal amount = dealRecord.getValueBigDecimal("amountInBaseCurrency"); - if(ownerUserId != null && amount != null) - { - revenueByOwner.merge(ownerUserId, amount, BigDecimal::add); - } - } - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Rank", "rank", "0.5fr", null), - new TableData.Column("html", "Owner", "owner", "2fr", null), - new TableData.Column("html", "Revenue", "revenue", "1fr", "right") - ); - - ////////////////////////// - // build ranked rows // - ////////////////////////// - List> sorted = new ArrayList<>(revenueByOwner.entrySet()); - sorted.sort(Comparator.comparing(Map.Entry::getValue).reversed()); - - List> rows = new ArrayList<>(); - int rank = 1; - for(Map.Entry entry : sorted) - { - rows.add(MapBuilder.of( - "rank", String.valueOf(rank), - "owner", entry.getKey(), - "revenue", entry.getValue().toPlainString() - )); - rank++; - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(25) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardWidgetProducer.java deleted file mode 100644 index 92c992c..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmSalesLeaderboardWidgetProducer.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the CRM Sales Leaderboard widget. - ** - ** Provides a TABLE view showing deals grouped by owner with total revenue - ** from closed-won deals, ranked descending. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; - - -/******************************************************************************* - ** Produces metadata for the crm_sales_leaderboard widget (TABLE type). - *******************************************************************************/ -public class CrmSalesLeaderboardWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_sales_leaderboard"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Sales Leaderboard") - .withTooltip("Top performers by closed-won deal revenue") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new QIcon("leaderboard")) - .withCodeReference(new QCodeReference(CrmSalesLeaderboardRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationRenderer.java deleted file mode 100644 index e22b0d0..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationRenderer.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Stage Duration widget. - ** - ** Queries DealStageHistory where durationInFromStageDays is not null, - ** computes average and median per fromStageId. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.deals.model.DealStageHistory; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders stage duration analytics as a table widget. - *******************************************************************************/ -public class CrmStageDurationRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // query history with durationInFromStageDays populated // - /////////////////////////////////////////////////////////// - QueryOutput historyOutput = new QueryAction().execute( - new QueryInput(DealStageHistory.TABLE_NAME) - .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("durationInFromStageDays", QCriteriaOperator.IS_NOT_BLANK)))); - - /////////////////////////////////////////////////////////// - // group durations by fromStageId // - /////////////////////////////////////////////////////////// - Map> durationsByStage = new HashMap<>(); - for(QRecord record : historyOutput.getRecords()) - { - Integer fromStageId = record.getValueInteger("fromStageId"); - Integer duration = record.getValueInteger("durationInFromStageDays"); - if(fromStageId != null && duration != null) - { - durationsByStage.computeIfAbsent(fromStageId, k -> new ArrayList<>()).add(duration); - } - } - - /////////////////////////////////////////////////////////// - // build stage name lookup // - /////////////////////////////////////////////////////////// - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME)); - - Map stageNames = new HashMap<>(); - for(QRecord record : stageOutput.getRecords()) - { - stageNames.put(record.getValueInteger("id"), record.getValueString("name")); - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Stage", "stageName", "2fr", null), - new TableData.Column("html", "Avg Days", "avgDays", "1fr", "right"), - new TableData.Column("html", "Median Days", "medianDays", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(Map.Entry> entry : durationsByStage.entrySet()) - { - Integer stageId = entry.getKey(); - List durations = entry.getValue(); - - BigDecimal avg = BigDecimal.valueOf(durations.stream().mapToInt(Integer::intValue).average().orElse(0)) - .setScale(1, RoundingMode.HALF_UP); - - Collections.sort(durations); - int medianIndex = durations.size() / 2; - int median = durations.size() % 2 == 0 - ? (durations.get(medianIndex - 1) + durations.get(medianIndex)) / 2 - : durations.get(medianIndex); - - String stageName = stageNames.getOrDefault(stageId, "Stage " + stageId); - - rows.add(MapBuilder.of( - "stageName", stageName, - "avgDays", avg, - "medianDays", median - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(25) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationWidgetProducer.java deleted file mode 100644 index b824858..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmStageDurationWidgetProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Stage Duration widget. - ** - ** Shows average and median days spent in each pipeline stage. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_stage_duration widget (TABLE type). - *******************************************************************************/ -public class CrmStageDurationWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_stage_duration"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Stage Duration") - .withTooltip("Average and median days per pipeline stage") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("timer")) - .withCodeReference(new QCodeReference(CrmStageDurationRenderer.class)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateRenderer.java deleted file mode 100644 index b829377..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateRenderer.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Renderer for the Win Rate widget. - ** - ** Queries DealStageHistory for transitions to closedWon and closedLost stages, - ** groups by month, and computes win rate percentage. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.Instant; -import java.time.YearMonth; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import com.kingsrook.qbits.crm.deals.model.DealStageHistory; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; -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.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; - - -/******************************************************************************* - ** Renders win rate analytics as a table widget. - *******************************************************************************/ -public class CrmWinRateRenderer extends AbstractWidgetRenderer -{ - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public RenderWidgetOutput render(RenderWidgetInput input) throws QException - { - /////////////////////////////////////////////////////////// - // find closedWon and closedLost stage IDs // - /////////////////////////////////////////////////////////// - Set closedWonStageIds = new HashSet<>(); - Set closedLostStageIds = new HashSet<>(); - - QueryOutput stageOutput = new QueryAction().execute( - new QueryInput(PipelineStage.TABLE_NAME)); - - for(QRecord record : stageOutput.getRecords()) - { - if(Boolean.TRUE.equals(record.getValueBoolean("isClosedWon"))) - { - closedWonStageIds.add(record.getValueInteger("id")); - } - if(Boolean.TRUE.equals(record.getValueBoolean("isClosedLost"))) - { - closedLostStageIds.add(record.getValueInteger("id")); - } - } - - /////////////////////////////////////////////////////////// - // query all deal stage history // - /////////////////////////////////////////////////////////// - QueryOutput historyOutput = new QueryAction().execute( - new QueryInput(DealStageHistory.TABLE_NAME)); - - /////////////////////////////////////////////////////////// - // group by month // - /////////////////////////////////////////////////////////// - Map monthData = new TreeMap<>(); // [won, lost] - - for(QRecord record : historyOutput.getRecords()) - { - Integer toStageId = record.getValueInteger("toStageId"); - Instant transitionDate = record.getValueInstant("transitionDate"); - - if(toStageId == null || transitionDate == null) - { - continue; - } - - boolean isWon = closedWonStageIds.contains(toStageId); - boolean isLost = closedLostStageIds.contains(toStageId); - - if(!isWon && !isLost) - { - continue; - } - - YearMonth month = YearMonth.from(transitionDate.atZone(ZoneId.systemDefault())); - int[] counts = monthData.computeIfAbsent(month, k -> new int[2]); - if(isWon) - { - counts[0]++; - } - if(isLost) - { - counts[1]++; - } - } - - /////////////////////////// - // build table columns // - /////////////////////////// - List columns = List.of( - new TableData.Column("html", "Month", "month", "1fr", null), - new TableData.Column("html", "Won", "won", "1fr", "right"), - new TableData.Column("html", "Lost", "lost", "1fr", "right"), - new TableData.Column("html", "Win Rate %", "winRate", "1fr", "right") - ); - - ////////////////////////// - // build table rows // - ////////////////////////// - List> rows = new ArrayList<>(); - for(Map.Entry entry : monthData.entrySet()) - { - int won = entry.getValue()[0]; - int lost = entry.getValue()[1]; - int total = won + lost; - - BigDecimal winRate = total > 0 - ? BigDecimal.valueOf(won).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP) - : BigDecimal.ZERO; - - rows.add(MapBuilder.of( - "month", entry.getKey().toString(), - "won", won, - "lost", lost, - "winRate", winRate + "%" - )); - } - - TableData tableData = new TableData(null, columns, rows) - .withRowsPerPage(25) - .withHidePaginationDropdown(true); - - return (new RenderWidgetOutput(tableData)); - } - -} diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateWidgetProducer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateWidgetProducer.java deleted file mode 100644 index ae4028b..0000000 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmWinRateWidgetProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** MetaData producer for the Win Rate widget. - ** - ** Shows won/lost counts and win rate percentage grouped by month. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; -import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; -import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; - - -/******************************************************************************* - ** Produces metadata for the crm_win_rate widget (TABLE type). - *******************************************************************************/ -public class CrmWinRateWidgetProducer extends MetaDataProducer -{ - public static final String NAME = "crm_win_rate"; - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public QWidgetMetaData produce(QInstance qInstance) throws QException - { - return new QWidgetMetaData() - .withName(NAME) - .withType(WidgetType.TABLE.getType()) - .withGridColumns(6) - .withIsCard(true) - .withLabel("Win Rate") - .withTooltip("Monthly win/loss counts and win rate") - .withShowReloadButton(true) - .withIcon("topRightInsideCard", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("analytics")) - .withCodeReference(new QCodeReference(CrmWinRateRenderer.class)); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetTest.java deleted file mode 100644 index f293088..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityCountWidgetTest.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for CrmActivityCountWidget. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -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.MultiStatisticsData; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertNotNull; - - -/******************************************************************************* - ** Tests for the CRM Activity Count widget renderer. - *******************************************************************************/ -class CrmActivityCountWidgetTest extends BaseTest -{ - - /******************************************************************************* - ** Insert activities of different types, render widget, verify counts. - *******************************************************************************/ - @Test - void testRenderActivityCounts() throws QException - { - /////////////////////////////////////// - // set up reference data // - /////////////////////////////////////// - Integer callTypeId = insertActivityType("Call"); - Integer emailTypeId = insertActivityType("Email"); - Integer meetingTypeId = insertActivityType("Meeting"); - - /////////////////////////////////////// - // insert activities (today) // - /////////////////////////////////////// - insertActivity(callTypeId, "Call 1"); - insertActivity(callTypeId, "Call 2"); - insertActivity(emailTypeId, "Email 1"); - insertActivity(meetingTypeId, "Meeting 1"); - - /////////////////////////////////////// - // render the widget // - /////////////////////////////////////// - CrmActivityCountRenderer renderer = new CrmActivityCountRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmActivityCountWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - assertNotNull(widgetOutput); - assertNotNull(widgetOutput.getWidgetData()); - assertThat(widgetOutput.getWidgetData()).isInstanceOf(MultiStatisticsData.class); - - MultiStatisticsData data = (MultiStatisticsData) widgetOutput.getWidgetData(); - assertNotNull(data.getStatisticsGroupData()); - - //////////////////////////////////////////////////////// - // we should have groups for each activity type used // - //////////////////////////////////////////////////////// - assertThat(data.getStatisticsGroupData()).hasSizeGreaterThanOrEqualTo(2); - } - - - - /******************************************************************************* - ** Render widget with no activities and verify empty output. - *******************************************************************************/ - @Test - void testRenderEmptyCounts() throws QException - { - CrmActivityCountRenderer renderer = new CrmActivityCountRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmActivityCountWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - MultiStatisticsData data = (MultiStatisticsData) widgetOutput.getWidgetData(); - assertThat(data.getStatisticsGroupData()).isEmpty(); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private Integer insertActivityType(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("phone") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert an Activity. - ***************************************************************************/ - private void insertActivity(Integer typeId, String subject) throws QException - { - new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(typeId) - .withSubject(subject) - .withOwnerUserId("user-001") - .withIsCompleted(false))); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetTest.java deleted file mode 100644 index d39fd3b..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmActivityFeedWidgetTest.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for CrmActivityFeedWidget. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -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 org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - - -/******************************************************************************* - ** Tests for the CRM Activity Feed widget renderer. - *******************************************************************************/ -class CrmActivityFeedWidgetTest extends BaseTest -{ - - /******************************************************************************* - ** Insert activities, render widget, verify output contains correct rows. - *******************************************************************************/ - @Test - void testRenderActivityFeed() throws QException - { - /////////////////////////////////////// - // set up reference data // - /////////////////////////////////////// - Integer callTypeId = insertActivityType("Call"); - Integer emailTypeId = insertActivityType("Email"); - - /////////////////////////////////////// - // insert activities // - /////////////////////////////////////// - insertActivity(callTypeId, "Client call", 1, null); - insertActivity(emailTypeId, "Follow-up email", 2, null); - insertActivity(callTypeId, "Team standup", null, 100); - - /////////////////////////////////////// - // render the widget // - /////////////////////////////////////// - CrmActivityFeedRenderer renderer = new CrmActivityFeedRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmActivityFeedWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - assertNotNull(widgetOutput); - assertNotNull(widgetOutput.getWidgetData()); - assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class); - - TableData tableData = (TableData) widgetOutput.getWidgetData(); - List> rows = tableData.getRows(); - - assertEquals(3, rows.size()); - - ///////////////////////////////////////////////////////////// - // verify columns are present in at least the first row // - ///////////////////////////////////////////////////////////// - Map firstRow = rows.get(0); - assertThat(firstRow).containsKey("subject"); - assertThat(firstRow).containsKey("activityType"); - assertThat(firstRow).containsKey("contact"); - assertThat(firstRow).containsKey("company"); - assertThat(firstRow).containsKey("date"); - } - - - - /******************************************************************************* - ** Render widget with no activities and verify empty output. - *******************************************************************************/ - @Test - void testRenderEmptyFeed() throws QException - { - CrmActivityFeedRenderer renderer = new CrmActivityFeedRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmActivityFeedWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - TableData tableData = (TableData) widgetOutput.getWidgetData(); - assertEquals(0, tableData.getRows().size()); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private Integer insertActivityType(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("phone") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert an Activity. - ***************************************************************************/ - private void insertActivity(Integer typeId, String subject, Integer contactId, Integer companyId) throws QException - { - new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(typeId) - .withSubject(subject) - .withContactId(contactId) - .withCompanyId(companyId) - .withOwnerUserId("user-001") - .withIsCompleted(false))); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetTest.java deleted file mode 100644 index 38ee364..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmAvgDealSizeWidgetTest.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for CrmAvgDealSizeRenderer. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.time.LocalDate; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.Pipeline; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -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 org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - - -/******************************************************************************* - ** Tests for the Average Deal Size widget: with data and empty results. - *******************************************************************************/ -class CrmAvgDealSizeWidgetTest extends BaseTest -{ - - /******************************************************************************* - ** Test render with closed-won deals. - *******************************************************************************/ - @Test - void testRenderWithData() throws QException - { - Integer pipelineId = insertPipeline("Sales"); - Integer wonStageId = insertPipelineStage(pipelineId, "Closed Won", 3, true, false); - - insertDeal("Deal A", pipelineId, wonStageId, new BigDecimal("10000"), new BigDecimal("10000")); - insertDeal("Deal B", pipelineId, wonStageId, new BigDecimal("20000"), new BigDecimal("20000")); - - CrmAvgDealSizeRenderer renderer = new CrmAvgDealSizeRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmAvgDealSizeWidgetProducer().produce(null)); - - RenderWidgetOutput output = renderer.render(widgetInput); - TableData tableData = (TableData) output.getWidgetData(); - - assertThat(tableData.getRows()).hasSize(2); - assertThat(tableData.getRows().get(0).get("value")).isEqualTo("$15000.00"); - assertThat(tableData.getRows().get(1).get("value")).isEqualTo("2"); - } - - - - /******************************************************************************* - ** Test render with no closed-won deals shows zero. - *******************************************************************************/ - @Test - void testRenderEmpty() throws QException - { - CrmAvgDealSizeRenderer renderer = new CrmAvgDealSizeRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmAvgDealSizeWidgetProducer().produce(null)); - - RenderWidgetOutput output = renderer.render(widgetInput); - TableData tableData = (TableData) output.getWidgetData(); - - assertThat(tableData.getRows()).hasSize(2); - assertThat(tableData.getRows().get(0).get("value")).isEqualTo("$0"); - assertThat(tableData.getRows().get(1).get("value")).isEqualTo("0"); - } - - - - /******************************************************************************* - ** Test render with deal using amount field (null amountInBaseCurrency). - *******************************************************************************/ - @Test - void testRenderUsesAmountFallback() throws QException - { - Integer pipelineId = insertPipeline("Sales"); - Integer wonStageId = insertPipelineStage(pipelineId, "Won", 3, true, false); - - insertDeal("Deal C", pipelineId, wonStageId, new BigDecimal("5000"), null); - - CrmAvgDealSizeRenderer renderer = new CrmAvgDealSizeRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmAvgDealSizeWidgetProducer().produce(null)); - - RenderWidgetOutput output = renderer.render(widgetInput); - TableData tableData = (TableData) output.getWidgetData(); - - assertThat(tableData.getRows().get(0).get("value")).isEqualTo("$5000.00"); - } - - - - /******************************************************************************* - ** Insert Pipeline. - *******************************************************************************/ - private Integer insertPipeline(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(Pipeline.TABLE_NAME).withRecordEntity( - new Pipeline().withName(name).withIsActive(true).withIsDefault(false))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /******************************************************************************* - ** Insert Pipeline Stage. - *******************************************************************************/ - private Integer insertPipelineStage(Integer pipelineId, String name, int sortOrder, - boolean isClosedWon, boolean isClosedLost) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity( - new PipelineStage() - .withPipelineId(pipelineId) - .withName(name) - .withSortOrder(sortOrder) - .withProbabilityPct(isClosedWon ? 100 : 0) - .withIsClosedWon(isClosedWon) - .withIsClosedLost(isClosedLost))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /******************************************************************************* - ** Insert Deal. - *******************************************************************************/ - private void insertDeal(String name, Integer pipelineId, Integer stageId, - BigDecimal amount, BigDecimal amountInBase) throws QException - { - new InsertAction().execute( - new InsertInput(Deal.TABLE_NAME).withRecordEntity( - new Deal() - .withName(name) - .withPipelineId(pipelineId) - .withPipelineStageId(stageId) - .withAmount(amount) - .withAmountInBaseCurrency(amountInBase) - .withActualCloseDate(LocalDate.now()) - .withOwnerUserId("user-001"))); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java index 1038c74..6f4a862 100644 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java @@ -19,180 +19,60 @@ */ -/******************************************************************************* - ** Unit tests for CrmContactTimelineWidget. - *******************************************************************************/ - package com.kingsrook.qbits.crm.widgets; -import java.util.Map; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.activities.model.Activity; -import com.kingsrook.qbits.crm.activities.model.ActivityType; -import com.kingsrook.qbits.crm.core.model.Contact; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.context.QContext; 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.StepperData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; /******************************************************************************* - ** Tests for the CRM Contact Timeline widget renderer. + ** Contact record timeline includes all four sources with no cross-contact rows. *******************************************************************************/ class CrmContactTimelineWidgetTest extends BaseTest { - /******************************************************************************* - ** Insert activities for a contact, render widget, verify timeline steps. + ** The record-view id selects and merges the four dated sources. *******************************************************************************/ @Test - void testRenderContactTimeline() throws QException + void timeline() throws Exception { - /////////////////////////////////////// - // set up reference data // - /////////////////////////////////////// - Integer activityTypeId = insertActivityType("Call"); - Integer contactId = insertContact("Alice", "A"); - - /////////////////////////////////////// - // insert activities for the contact // - /////////////////////////////////////// - insertActivity(activityTypeId, "First call", contactId, "Initial outreach"); - insertActivity(activityTypeId, "Second call", contactId, "Follow-up discussion"); - insertActivity(activityTypeId, "Third call", contactId, "Closing discussion"); - - /////////////////////////////////////// - // render the widget // - /////////////////////////////////////// - CrmContactTimelineRenderer renderer = new CrmContactTimelineRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmContactTimelineWidgetProducer().produce(null)); - widgetInput.setQueryParams(Map.of("contactId", String.valueOf(contactId))); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - assertNotNull(widgetOutput); - assertNotNull(widgetOutput.getWidgetData()); - assertThat(widgetOutput.getWidgetData()).isInstanceOf(StepperData.class); - - StepperData stepperData = (StepperData) widgetOutput.getWidgetData(); - assertEquals(3, stepperData.getSteps().size()); - assertEquals("First call", stepperData.getSteps().get(0).getLabel()); - assertEquals("Second call", stepperData.getSteps().get(1).getLabel()); - assertEquals("Third call", stepperData.getSteps().get(2).getLabel()); - - /////////////////////////////////////////////////////////// - // activeStep should be the last (most recent) activity // - /////////////////////////////////////////////////////////// - assertEquals(2, stepperData.getActiveStep()); + CrmHomeWidgetsTest.seed("crm_contact", new QRecord().withValue("id", 1)); + int days = 0; + for(String table : List.of("crm_activity", "crm_email_message", "crm_form_submission", "crm_consent_record")) + { + CrmHomeWidgetsTest.seed(table, new QRecord().withValue("contactId", 1).withValue("createDate", Instant.now().minus(days++, ChronoUnit.DAYS))); + CrmHomeWidgetsTest.seed(table, new QRecord().withValue("contactId", 2).withValue("createDate", Instant.now())); + } + RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_contact_timeline")); + input.addQueryParam("id", "1"); + TableData data = (TableData) new CrmContactTimelineRenderer().render(input).getWidgetData(); + assertThat(data.getRows()).hasSize(4); + CrmHomeWidgetsTest.assertCell(data, 0, "second", "Activity"); + CrmHomeWidgetsTest.assertCell(data, 1, "second", "Email message"); + CrmHomeWidgetsTest.assertCell(data, 2, "second", "Web lead"); + CrmHomeWidgetsTest.assertCell(data, 3, "second", "Consent record"); + assertThat(data.getColumns()).hasSize(3); } - /******************************************************************************* - ** Render widget with no contactId and verify empty timeline. + ** Missing and nonexistent parent ids never query all contacts' histories. *******************************************************************************/ @Test - void testRenderEmptyTimeline() throws QException + void noParent() throws Exception { - CrmContactTimelineRenderer renderer = new CrmContactTimelineRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmContactTimelineWidgetProducer().produce(null)); - widgetInput.setQueryParams(Map.of()); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - StepperData stepperData = (StepperData) widgetOutput.getWidgetData(); - assertEquals(0, stepperData.getSteps().size()); + RenderWidgetInput input = new RenderWidgetInput(); + assertThat(((TableData) new CrmContactTimelineRenderer().render(input).getWidgetData()).getRows()).isEmpty(); + input.addQueryParam("id", "999"); + assertThat(((TableData) new CrmContactTimelineRenderer().render(input).getWidgetData()).getRows()).isEmpty(); } - - - - /******************************************************************************* - ** Verify that activities for a different contact are not included. - *******************************************************************************/ - @Test - void testTimelineOnlyShowsTargetContact() throws QException - { - Integer activityTypeId = insertActivityType("Note"); - Integer contactId1 = insertContact("Alice", "A"); - Integer contactId2 = insertContact("Bob", "B"); - - insertActivity(activityTypeId, "Alice's note", contactId1, "For Alice"); - insertActivity(activityTypeId, "Bob's note", contactId2, "For Bob"); - insertActivity(activityTypeId, "Another Alice note", contactId1, "Also for Alice"); - - CrmContactTimelineRenderer renderer = new CrmContactTimelineRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmContactTimelineWidgetProducer().produce(null)); - widgetInput.setQueryParams(Map.of("contactId", String.valueOf(contactId1))); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - StepperData stepperData = (StepperData) widgetOutput.getWidgetData(); - assertEquals(2, stepperData.getSteps().size()); - assertEquals("Alice's note", stepperData.getSteps().get(0).getLabel()); - assertEquals("Another Alice note", stepperData.getSteps().get(1).getLabel()); - } - - - - /*************************************************************************** - ** Helper: insert an ActivityType and return its id. - ***************************************************************************/ - private Integer insertActivityType(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(ActivityType.TABLE_NAME) - .withRecordEntity(new ActivityType() - .withName(name) - .withIconName("phone") - .withIsSystem(false) - .withSortOrder(1) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert a Contact and return its id. - ***************************************************************************/ - private Integer insertContact(String firstName, String lastName) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(Contact.TABLE_NAME).withRecordEntity( - new Contact() - .withFirstName(firstName) - .withLastName(lastName) - .withOwnerUserId("user-001"))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert an Activity linked to a contact. - ***************************************************************************/ - private void insertActivity(Integer typeId, String subject, Integer contactId, String description) throws QException - { - new InsertAction().execute( - new InsertInput(Activity.TABLE_NAME).withRecordEntity( - new Activity() - .withActivityTypeId(typeId) - .withSubject(subject) - .withContactId(contactId) - .withDescription(description) - .withOwnerUserId("user-001") - .withIsCompleted(false))); - } - } diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetTest.java deleted file mode 100644 index d33345e..0000000 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmDealForecastWidgetTest.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** Unit tests for CrmDealForecastWidget. - *******************************************************************************/ - -package com.kingsrook.qbits.crm.widgets; - - -import java.math.BigDecimal; -import java.time.Instant; -import java.time.LocalDate; -import java.util.List; -import java.util.Map; -import com.kingsrook.qbits.crm.BaseTest; -import com.kingsrook.qbits.crm.deals.model.Deal; -import com.kingsrook.qbits.crm.deals.model.Pipeline; -import com.kingsrook.qbits.crm.deals.model.PipelineStage; -import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; -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 org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - - -/******************************************************************************* - ** Tests for the CRM Deal Forecast widget renderer. - *******************************************************************************/ -class CrmDealForecastWidgetTest extends BaseTest -{ - - /******************************************************************************* - ** Create deals with different expectedCloseDates, render, verify grouping. - *******************************************************************************/ - @Test - void testRenderDealForecast() throws QException - { - /////////////////////////////////////////// - // set up pipeline with open and closed // - /////////////////////////////////////////// - Integer pipelineId = insertPipeline("Sales Pipeline"); - Integer openStageId = insertStage(pipelineId, "Qualification", 1, 10, false, false); - Integer wonStageId = insertStage(pipelineId, "Closed Won", 2, 100, true, false); - - /////////////////////////////////////////// - // insert open deals in different months // - /////////////////////////////////////////// - insertDealWithExpectedClose("Deal Jan", pipelineId, openStageId, - new BigDecimal("10000"), new BigDecimal("1000"), LocalDate.of(2026, 1, 15)); - insertDealWithExpectedClose("Deal Jan 2", pipelineId, openStageId, - new BigDecimal("5000"), new BigDecimal("500"), LocalDate.of(2026, 1, 20)); - insertDealWithExpectedClose("Deal Mar", pipelineId, openStageId, - new BigDecimal("20000"), new BigDecimal("10000"), LocalDate.of(2026, 3, 1)); - - //////////////////////////////////////////////////////////// - // insert a closed deal -- should NOT appear in forecast // - //////////////////////////////////////////////////////////// - insertDealWithExpectedClose("Closed Deal", pipelineId, wonStageId, - new BigDecimal("15000"), new BigDecimal("15000"), LocalDate.of(2026, 2, 1)); - - /////////////////////////////////////////// - // render the widget // - /////////////////////////////////////////// - CrmDealForecastRenderer renderer = new CrmDealForecastRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmDealForecastWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - assertNotNull(widgetOutput); - assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class); - - TableData tableData = (TableData) widgetOutput.getWidgetData(); - List> rows = tableData.getRows(); - - ////////////////////////////////////////////////////////////// - // should have 2 months (Jan and Mar), not Feb (closed) // - ////////////////////////////////////////////////////////////// - assertEquals(2, rows.size()); - - /////////////////////////////////////////// - // verify Jan grouping // - /////////////////////////////////////////// - Map janRow = rows.get(0); - assertEquals("2026-01", janRow.get("month")); - assertEquals(2, janRow.get("dealCount")); - - /////////////////////////////////////////// - // verify Mar grouping // - /////////////////////////////////////////// - Map marRow = rows.get(1); - assertEquals("2026-03", marRow.get("month")); - assertEquals(1, marRow.get("dealCount")); - } - - - - /******************************************************************************* - ** Render forecast with no open deals. - *******************************************************************************/ - @Test - void testRenderEmptyForecast() throws QException - { - CrmDealForecastRenderer renderer = new CrmDealForecastRenderer(); - RenderWidgetInput widgetInput = new RenderWidgetInput(); - widgetInput.setWidgetMetaData(new CrmDealForecastWidgetProducer().produce(null)); - - RenderWidgetOutput widgetOutput = renderer.render(widgetInput); - - TableData tableData = (TableData) widgetOutput.getWidgetData(); - assertEquals(0, tableData.getRows().size()); - } - - - - /*************************************************************************** - ** Helper: insert a Pipeline and return its id. - ***************************************************************************/ - private Integer insertPipeline(String name) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(Pipeline.TABLE_NAME) - .withRecordEntity(new Pipeline() - .withName(name) - .withIsDefault(true) - .withIsActive(true))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert a PipelineStage and return its id. - ***************************************************************************/ - private Integer insertStage(Integer pipelineId, String name, Integer sortOrder, - Integer probabilityPct, Boolean isClosedWon, Boolean isClosedLost) throws QException - { - InsertOutput output = new InsertAction().execute( - new InsertInput(PipelineStage.TABLE_NAME) - .withRecordEntity(new PipelineStage() - .withPipelineId(pipelineId) - .withName(name) - .withSortOrder(sortOrder) - .withProbabilityPct(probabilityPct) - .withIsClosedWon(isClosedWon) - .withIsClosedLost(isClosedLost))); - return (output.getRecords().get(0).getValueInteger("id")); - } - - - - /*************************************************************************** - ** Helper: insert a Deal with expectedCloseDate. - ***************************************************************************/ - private void insertDealWithExpectedClose(String name, Integer pipelineId, Integer stageId, - BigDecimal amount, BigDecimal weightedAmount, - LocalDate expectedCloseDate) throws QException - { - new InsertAction().execute( - new InsertInput(Deal.TABLE_NAME) - .withRecordEntity(new Deal() - .withName(name) - .withPipelineId(pipelineId) - .withPipelineStageId(stageId) - .withAmount(amount) - .withWeightedAmount(weightedAmount) - .withExpectedCloseDate(expectedCloseDate) - .withOwnerUserId("user-001") - .withStageEnteredDate(Instant.now()))); - } - -} diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java new file mode 100644 index 0000000..eba487b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java @@ -0,0 +1,357 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.crm.widgets; + + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ParentWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Seeded memory integration tests exercise the actual registered renderers. + *******************************************************************************/ +class CrmHomeWidgetsTest extends BaseTest +{ + /******************************************************************************* + ** All five Today metrics respect status, time, assignment and decimal values. + *******************************************************************************/ + @Test + void today() throws Exception + { + seedStages(); + deal(1, 1, "1234.56", "100.25", LocalDate.now(ZoneOffset.UTC), TEST_USER_ID); + deal(2, 2, "222.22", "222.22", LocalDate.now(ZoneOffset.UTC), TEST_USER_ID); + deal(3, 3, "9999", "9999", LocalDate.now(ZoneOffset.UTC), TEST_USER_ID); + seed("crm_contact", new QRecord().withValue("createDate", Instant.now().minus(5, ChronoUnit.DAYS))); + seed("crm_contact", new QRecord().withValue("createDate", Instant.now().minus(31, ChronoUnit.DAYS))); + seed("crm_form_submission", new QRecord().withValue("createDate", Instant.now().minus(6, ChronoUnit.DAYS))); + seed("crm_form_submission", new QRecord().withValue("createDate", Instant.now().minus(8, ChronoUnit.DAYS))); + activity(TEST_USER_ID, false, Instant.now().minus(1, ChronoUnit.DAYS)); + activity(TEST_USER_ID, true, Instant.now().minus(1, ChronoUnit.DAYS)); + activity("someone-else", false, Instant.now().minus(1, ChronoUnit.DAYS)); + activity(TEST_USER_ID, false, Instant.now().plus(1, ChronoUnit.DAYS)); + assertThat(((ParentWidgetData) render("crm_today")).getChildWidgetNameList()).containsExactlyElementsOf(CrmHomeWidgets.TODAY); + assertStatistic("crm_open_pipeline", "1234.56"); + assertStatistic("crm_won_month", "222.22"); + assertStatistic("crm_new_contacts", "1"); + assertStatistic("crm_new_web_leads", "1"); + assertStatistic("crm_overdue_activities", "1"); + QContext.getQSession().setIdReference(null); + assertStatistic("crm_overdue_activities", "0"); + } + + + /******************************************************************************* + ** Empty data renders zero statistics, six months and no goals card. + *******************************************************************************/ + @Test + void emptyHome() throws Exception + { + for(String name : CrmHomeWidgets.TODAY) + { + assertStatistic(name, "0"); + } + for(String name : CrmHomeWidgets.HOME.subList(1, CrmHomeWidgets.HOME.size())) + { + TableData data = (TableData) render(name); + assertThat(data.getRows()).hasSize(name.equals("crm_deal_forecast") ? 6 : 0); + } + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isFalse(); + } + + + /******************************************************************************* + ** The first active pipeline is selected, empty stages remain, currencies sum. + *******************************************************************************/ + @Test + void pipeline() throws Exception + { + seed("crm_pipeline", new QRecord().withValue("id", 0).withValue("name", "Inactive").withValue("isActive", false)); + seedStages(); + seed("crm_pipeline", new QRecord().withValue("id", 2).withValue("isActive", true)); + seed("crm_pipeline_stage", new QRecord().withValue("id", 4).withValue("pipelineId", 2).withValue("name", "Other")); + seed("crm_currency", new QRecord().withValue("currencyCode", "EUR").withValue("exchangeRateToBase", new BigDecimal("2"))); + deal(1, 1, "100.00", "20.50", LocalDate.now(), TEST_USER_ID); + seed("crm_deal", new QRecord().withValue("pipelineId", 1).withValue("pipelineStageId", 1).withValue("currencyCode", "EUR") + .withValue("amountInBaseCurrency", new BigDecimal("200")).withValue("weightedAmount", new BigDecimal("10"))); + seed("crm_deal", new QRecord().withValue("pipelineId", 2).withValue("pipelineStageId", 4).withValue("amountInBaseCurrency", new BigDecimal("9999"))); + TableData data = (TableData) render("crm_pipeline_summary"); + assertThat(data.getColumns()).hasSize(3); + assertThat(data.getRows()).hasSize(3); + assertCell(data, 0, "first", "Open (2 deals)"); + assertCell(data, 0, "second", "300.00"); + assertCell(data, 0, "third", "40.50"); + assertCell(data, 1, "first", "Won (0 deals)"); + assertThat(data.getDropdownNameList()).containsExactly("pipelineId"); + RenderWidgetInput selected = new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget("crm_pipeline_summary")); + selected.addQueryParam("pipelineId", "2"); + TableData other = (TableData) new CrmHomeRenderer().render(selected).getWidgetData(); + assertThat(other.getRows()).hasSize(1); + assertCell(other, 0, "first", "Other (1 deals)"); + selected.addQueryParam("pipelineId", "999"); + assertThat(((TableData) new CrmHomeRenderer().render(selected).getWidgetData()).getRows()).hasSize(3); + assertLinks(data); + } + + + /******************************************************************************* + ** Forecast includes month five, excludes month six and closed deals. + *******************************************************************************/ + @Test + void forecast() throws Exception + { + seedStages(); + LocalDate month = LocalDate.now(ZoneOffset.UTC).withDayOfMonth(1); + deal(1, 1, "100", "25.25", month, TEST_USER_ID); + deal(2, 1, "100", "75.75", month.plusMonths(5), TEST_USER_ID); + deal(3, 1, "100", "99", month.plusMonths(6), TEST_USER_ID); + deal(4, 2, "100", "99", month, TEST_USER_ID); + TableData data = (TableData) render("crm_deal_forecast"); + assertThat(data.getRows()).hasSize(6); + assertCell(data, 0, "second", "1"); + assertCell(data, 0, "third", "25.25"); + assertCell(data, 1, "second", "0"); + assertCell(data, 5, "third", "75.75"); + assertLinks(data); + } + + + /******************************************************************************* + ** Rotting deals are old enough, open, sorted oldest first and capped at ten. + *******************************************************************************/ + @Test + void needsAttention() throws Exception + { + seedStages(); + for(int i = 1; i <= 12; i++) + { + seed("crm_deal", new QRecord().withValue("name", "Deal " + i).withValue("pipelineStageId", 1) + .withValue("stageEnteredDate", Instant.now().minus(i + 10, ChronoUnit.DAYS))); + } + seed("crm_deal", new QRecord().withValue("name", "Fresh").withValue("pipelineStageId", 1).withValue("stageEnteredDate", Instant.now())); + seed("crm_deal", new QRecord().withValue("name", "Won").withValue("pipelineStageId", 2).withValue("stageEnteredDate", Instant.now().minus(90, ChronoUnit.DAYS))); + TableData data = (TableData) render("crm_rotting_deals"); + assertThat(data.getRows()).hasSize(10); + assertCell(data, 0, "first", "Deal 12"); + assertCell(data, 0, "second", "Open"); + assertCell(data, 0, "third", "22"); + assertLinks(data); + } + + + /******************************************************************************* + ** Recent leads have enum labels, newest-ten limits and safe record links. + *******************************************************************************/ + @Test + void recentWebLeads() throws Exception + { + for(int i = 1; i <= 12; i++) + { + seed("crm_form_submission", new QRecord().withValue("id", i).withValue("email", "lead" + i + "@example.test") + .withValue("formType", 2).withValue("conversionStatus", 3).withValue("createDate", Instant.now().minus(i, ChronoUnit.DAYS))); + } + seed("crm_form_submission", new QRecord().withValue("firstName", "