Compare commits

...
Author SHA1 Message Date
james.maes 7a9a38afe4 Merge remote-tracking branch 'origin/develop' into feature/app-home
# Conflicts:
#	src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java
#	src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java
#	src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java
2026-10-14 12:50:29 -05:00
james.maes 45df8cd380 Merge pull request #9 from QRun-IO/feature/intake-api
feat(api): CRM intake and marketing contracts, double opt-in, signed hooks
2026-10-14 12:48:48 -05:00
james.maes 43c6fe83b1 Merge remote-tracking branch 'origin/develop' into feature/app-home 2026-10-14 12:46:04 -05:00
james.maesandClaude Opus 5.5 8381850999 feat: child lists and joins on contact, company, deal, pipeline, sequence, list, rule and activity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:14 -05:00
james.maesandClaude Opus 5.5 85b83c8031 feat(home): real-data CRM home; retire trend widgets for data analysis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:13 -05:00
james.maesandClaude Opus 5.5 fc92690573 feat(app): one crm app with main and settings sections; processes never in nav
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:38:12 -05:00
70 changed files with 1594 additions and 5487 deletions

No files matched your search

+7 -5
View File
@@ -1,6 +1,6 @@
# QBit CRM
**0.2.0-SNAPSHOT — unreleased, P1 Platform.** CRM tables, processes and widgets for QQQ applications. This phase upgrades the build, names, permissions, audit integration and database schema. It does not complete the later 0.2.0 product phases.
**0.2.0-SNAPSHOT — unreleased, P1 Platform + P2 App, home and nav.** CRM tables, processes and widgets for QQQ applications. This work upgrades the build, names, permissions, audit integration and database schema, and supplies the CRM app and record navigation. It does not complete the later 0.2.0 product phases.
## Build and verification
@@ -50,7 +50,7 @@ new CrmQBitProducer()
qInstance.setEnforcePermissionsInCoreActions(true);
```
4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. Add tables, processes and widgets to the host UI as needed: `produceAppSection()` currently returns an empty CRM section; P1 does not provide an assembled app or home dashboard.
4. Map the host's authenticated roles to the permissions below. Configure the host's core audit storage. The `crm` app is enabled by default, with main and Settings navigation and a real-data home. Set `withAppEnabled(false)` to embed the tables in the host's own app. `produceAppSection()` returns the same populated main section; processes are record/table actions, never navigation entries.
## Configuration and access
@@ -68,7 +68,7 @@ qInstance.setEnforcePermissionsInCoreActions(true);
| Merge, bulk assignment, list and sequence management processes | `crm.manage.hasAccess` |
| GDPR anonymization | `crm.admin.hasAccess` |
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The widget provenance adapter supplies core's `SourceQBitAware` interface because RC.6's widget class lacks it.
Permissions are independent grants. Every CRM-produced table, process and widget has explicit `QPermissionRules` with `DenyBehavior.HIDDEN` and QBit provenance. `security.CrmPermissions.ALL` lists the grants for host administration and tests. The app and widget provenance adapters supply core's `SourceQBitAware` interface because RC.6's app and widget classes lack it.
## Tables
@@ -122,7 +122,9 @@ The QBit produces 25 processes with these names and labels. QQQ may additionally
## Current behavior and limits
The existing pipeline/stage logic, stage history, product totals, currency conversion, contact/company merge, activity logging, form auto-linking, list subscribe/unsubscribe, scoring and assignment processes remain. Fourteen custom dashboard widget producers remain available for host placement, together with generated child-record widgets.
The existing pipeline/stage logic, stage history, product totals, currency conversion, contact/company merge, activity logging, form auto-linking, list subscribe/unsubscribe, scoring and assignment processes remain. The CRM home contains Today (five standard statistics), Pipeline by stage, Forecast (current month plus five), Needs attention (ten oldest rotting deals), Recent web leads (latest ten), Goals (up to fifty current goals, hidden when none are visible), and Leaderboard (quarter-to-date won revenue by owner). Reporting boundaries use UTC. Monetary summaries use base currency, with weighted amounts grouped by currency before conversion. Aggregate rows link to their underlying records; stage name and deal count share a column to keep tables to three columns. The pipeline picker appears when multiple active pipelines are visible.
The contact Timeline shows the latest fifty recorded events from activities, email messages, form submissions and consent, newest first by creation date. Native child lists connect all specified parent screens. Deal primary-contact and email-message contact references are included in the fresh-install schema. The trend widgets, redundant recent-deals/activity-count widgets and activity feed have been retired.
Some process names describe unfinished behavior inherited from 0.1.0:
@@ -131,7 +133,7 @@ Some process names describe unfinished behavior inherited from 0.1.0:
- Scoring's date-decay operators remain unimplemented. Import Contacts expects pre-parsed input and does not provide the planned streamed bulk-load workflow.
- GDPR anonymization scrubs the contact, unenrolls sequences and records withdrawn consent. It does not yet erase all linked PII or core audit details. The P1 schema retains existing PII fields; classification, complete erasure and redesigned tenancy are later work.
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and full app navigation are not part of this phase.
The later intake APIs, public subscription routes, collaboration integration, real email transport, durable jobs and the remaining P2 actions/security/integration work are not part of this phase.
## Source layout
+4
View File
@@ -251,6 +251,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
probability_override_pct INTEGER,
weighted_amount DECIMAL(20,6),
company_id INTEGER,
primary_contact_id INTEGER,
owner_user_id TEXT NOT NULL,
lead_source_id INTEGER,
expected_close_date DATE,
@@ -266,6 +267,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_id ON crm_deal (pipeline_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_stage_id ON crm_deal (pipeline_stage_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_primary_contact_id ON crm_deal (primary_contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_company_id ON crm_deal (company_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_owner_user_id ON crm_deal (owner_user_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_lead_source_id ON crm_deal (lead_source_id);
@@ -363,6 +365,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
CREATE TABLE IF NOT EXISTS crm_email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
activity_id INTEGER,
contact_id INTEGER,
from_address VARCHAR(250) NOT NULL,
from_name VARCHAR(100),
to_addresses TEXT NOT NULL,
@@ -389,6 +392,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
modify_date TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_template_id ON crm_email_message (email_template_id);
@@ -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<String> MAIN_TABLES = List.of("crm_contact", "crm_company", "crm_deal", "crm_activity", "crm_form_submission", "crm_email_list");
public static final List<String> 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<String> 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);
}
}
@@ -51,6 +51,7 @@ public class CrmQBitConfig implements QBitConfig
private String defaultBackendNameForTables;
private Boolean inferBackendNames = true;
private Boolean appEnabled = true;
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private List<QFieldMetaData> securityFields;
private List<RecordSecurityLock> recordSecurityLocks;
@@ -195,6 +196,24 @@ public class CrmQBitConfig implements QBitConfig
}
/*******************************************************************************
** 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);
}
/*******************************************************************************
** Getter for tokenSecret
@@ -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;
@@ -60,7 +61,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;
/*******************************************************************************
@@ -106,13 +109,32 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
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()));
@@ -236,10 +258,7 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
*******************************************************************************/
public static QAppSection produceAppSection()
{
return new QAppSection()
.withName("crm")
.withLabel("CRM")
.withIcon(new QIcon().withName("contacts"));
return (CrmAppMetaData.mainSection());
}
@@ -295,6 +314,10 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
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()));
}
output.add(new QAlertTypeMetaData().withName("crm.lead.received").withLabel("New web lead")
.withDefaultSeverity(AlertSeverity.INFO).withSourceQBitName(getQBitMetaData().getName()));
output.add(new QAlertTypeMetaData().withName("crm.sequence.failing").withLabel("Sequence needs attention")
@@ -30,6 +30,7 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -63,6 +64,9 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
produceTableMetaData = true,
tableMetaDataCustomizer = Company.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "companyId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = Contact.class, joinFieldName = "companyId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Contacts", enabled = true, maxRows = 50)),
@@ -30,7 +30,9 @@ import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.email.model.EmailMessage;
import com.kingsrook.qbits.crm.email.model.SequenceEnrollment;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -62,12 +64,24 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
produceTableMetaData = true,
tableMetaDataCustomizer = Contact.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = Deal.class, joinFieldName = "primaryContactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "List Memberships", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = SequenceEnrollment.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Enrollments", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = EmailMessage.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Email Messages", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = FormSubmission.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Web Leads", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = Activity.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 50)),
@ChildTable(childTableEntityClass = DealContact.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)),
@ChildTable(childTableEntityClass = ConsentRecord.class, joinFieldName = "contactId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Consent Records", enabled = true, maxRows = 25))
@@ -35,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
@@ -48,7 +51,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
@QMetaDataProducingEntity(
producePossibleValueSource = true,
produceTableMetaData = true,
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class
tableMetaDataCustomizer = EmailList.TableMetaDataCustomizer.class,
childTables = {
@ChildTable(childTableEntityClass = EmailListMember.class, joinFieldName = "emailListId",
childJoin = @ChildJoin(enabled = true),
childRecordListWidget = @ChildRecordListWidget(label = "Members", enabled = true, maxRows = 25))
}
)
public class EmailList extends QRecordEntity
{
@@ -125,6 +125,8 @@ public class MergeContactsProcess implements BackendStep, MetaDataProducerInterf
// Re-link activities from secondary //
///////////////////////////////////////////////
relinkRecords(Activity.TABLE_NAME, "contactId", secondaryContactId, primaryContactId);
relinkRecords("crm_email_message", "contactId", secondaryContactId, primaryContactId);
relinkRecords("crm_deal", "primaryContactId", secondaryContactId, primaryContactId);
///////////////////////////////////////////////
// Re-link DealContact rows //
@@ -33,6 +33,7 @@ import java.util.List;
import com.kingsrook.qbits.crm.CrmQBitConfig;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.Company;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.LeadSource;
import com.kingsrook.qbits.crm.core.model.enums.CrmDealPriority;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -118,7 +119,7 @@ public class Deal extends QRecordEntity
.withRecordLabelFields("name")
.withSection(t1Section)
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
List.of("companyId", "ownerUserId", "leadSourceId", "priority",
List.of("primaryContactId", "companyId", "ownerUserId", "leadSourceId", "priority",
"probabilityOverridePct", "expectedCloseDate", "actualCloseDate",
"winLossReasonId", "weightedAmount", "amountInBaseCurrency")))
.withSection(SectionFactory.defaultT2("description"))
@@ -191,6 +192,9 @@ public class Deal extends QRecordEntity
@QField(possibleValueSourceName = Company.TABLE_NAME)
private Integer companyId;
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer primaryContactId;
@QField(isRequired = true)
private String ownerUserId;
@@ -894,4 +898,32 @@ public class Deal extends QRecordEntity
return (this);
}
/*******************************************************************************
** The contact primarily responsible for this opportunity.
*******************************************************************************/
public Integer getPrimaryContactId()
{
return (primaryContactId);
}
/*******************************************************************************
** Set the primary contact.
*******************************************************************************/
public void setPrimaryContactId(Integer primaryContactId)
{
this.primaryContactId = primaryContactId;
}
/*******************************************************************************
** Set the primary contact fluently.
*******************************************************************************/
public Deal withPrimaryContactId(Integer primaryContactId)
{
this.primaryContactId = primaryContactId;
return (this);
}
}
@@ -127,6 +127,7 @@ public class CloneDealProcess implements BackendStep, MetaDataProducerInterface<
.withValue("amount", sourceDeal.getAmount())
.withValue("currencyCode", sourceDeal.getCurrencyCode())
.withValue("companyId", sourceDeal.getCompanyId())
.withValue("primaryContactId", sourceDeal.getPrimaryContactId())
.withValue("ownerUserId", sourceDeal.getOwnerUserId())
.withValue("leadSourceId", sourceDeal.getLeadSourceId())
.withValue("expectedCloseDate", sourceDeal.getExpectedCloseDate())
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.crm.deals.model.Deal;
import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
@@ -131,6 +132,11 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
new InsertAction().execute(
new InsertInput(DealContact.TABLE_NAME).withRecordEntity(dealContact));
if(Boolean.TRUE.equals(isPrimary))
{
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
.withValue("id", dealId).withValue("primaryContactId", contactId)));
}
}
else if("REMOVE".equalsIgnoreCase(action))
{
@@ -156,6 +162,12 @@ public class ManageContactOnDealProcess implements BackendStep, MetaDataProducer
deleteInput.setTableName(DealContact.TABLE_NAME);
deleteInput.setPrimaryKeys(idsToDelete.stream().map(id -> (java.io.Serializable) id).toList());
new DeleteAction().execute(deleteInput);
QRecord deal = GetAction.execute(Deal.TABLE_NAME, dealId);
if(deal != null && contactId.equals(deal.getValueInteger("primaryContactId")))
{
new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(new QRecord()
.withValue("id", dealId).withValue("primaryContactId", null)));
}
}
else
{
@@ -29,6 +29,7 @@ package com.kingsrook.qbits.crm.email.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.activities.model.Activity;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType;
import com.kingsrook.qbits.crm.core.model.enums.CrmDirection;
import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus;
@@ -86,7 +87,7 @@ public class EmailMessage extends QRecordEntity
List.of("isTracked", "openCount", "clickCount", "firstOpenedDate", "firstClickedDate",
"bounceType", "isSpamComplaint")))
.withSection(new QFieldSection("references", new QIcon("link"), Tier.T2,
List.of("activityId", "emailTemplateId", "sequenceEnrollmentId",
List.of("contactId", "activityId", "emailTemplateId", "sequenceEnrollmentId",
"threadId", "replyToEmailId")))
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
@@ -102,6 +103,9 @@ public class EmailMessage extends QRecordEntity
@QField(possibleValueSourceName = Activity.TABLE_NAME)
private Integer activityId;
@QField(possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
private String fromAddress;
@@ -999,4 +1003,32 @@ public class EmailMessage extends QRecordEntity
return (this);
}
/*******************************************************************************
** Contact whose record includes this message.
*******************************************************************************/
public Integer getContactId()
{
return (contactId);
}
/*******************************************************************************
** Set the message's contact.
*******************************************************************************/
public void setContactId(Integer contactId)
{
this.contactId = contactId;
}
/*******************************************************************************
** Set the message's contact fluently.
*******************************************************************************/
public EmailMessage withContactId(Integer contactId)
{
this.contactId = contactId;
return (this);
}
}
@@ -28,6 +28,7 @@ package com.kingsrook.qbits.crm.email.model;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.crm.core.model.Contact;
import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
@@ -97,7 +98,7 @@ public class SequenceEnrollment extends QRecordEntity
@QField(isRequired = true, possibleValueSourceName = EmailSequence.TABLE_NAME)
private Integer sequenceId;
@QField(isRequired = true)
@QField(isRequired = true, possibleValueSourceName = Contact.TABLE_NAME)
private Integer contactId;
@QField()
@@ -189,6 +189,7 @@ public class SendEmailProcess implements BackendStep, MetaDataProducerInterface<
EmailMessage emailMessage = new EmailMessage()
.withActivityId(activityId)
.withContactId(contactId)
.withFromAddress(sessionUserId)
.withFromName(sessionUserId)
.withToAddresses(toAddress)
@@ -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<Integer, String> 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<Integer, Integer> 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<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
for(Map.Entry<Integer, Integer> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<TableData.Column> 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<Map<String, Object>> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer> 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<TableData.Column> columns = List.of(
new TableData.Column("html", "Metric", "metric", "1fr", null),
new TableData.Column("html", "Value", "value", "1fr", "right")
);
List<Map<String, Object>> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Map<String, Object>> 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<StepperData.Step> steps = new ArrayList<>();
for(QRecord record : queryOutput.getRecords())
{
String subject = record.getValueString("subject");
if(subject == null)
List<QRecord> 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")));
}
}
@@ -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<QWidgetMetaData>
{
@@ -55,8 +56,9 @@ public class CrmContactTimelineWidgetProducer extends MetaDataProducer<QWidgetMe
{
return new QWidgetMetaData()
.withName(NAME)
.withType(WidgetType.STEPPER.getType())
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE))
.withIsCard(true)
.withLabel("Contact Timeline")
.withTooltip("Chronological activity timeline for this contact")
@@ -1,189 +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 Deal Forecast widget.
**
** Queries open deals (not in closed stages) and groups them by expected close
** month. Returns a table with columns: month, dealCount, totalAmount,
** totalWeightedAmount.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
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 deal forecast as a table widget showing open deal aggregates
** grouped by expected close month.
*******************************************************************************/
public class CrmDealForecastRenderer extends AbstractWidgetRenderer
{
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
///////////////////////////////////////////////////////////////
// find all closed stage IDs to exclude //
///////////////////////////////////////////////////////////////
Set<Integer> 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<YearMonth, Integer> countByMonth = new TreeMap<>();
Map<YearMonth, BigDecimal> amountByMonth = new TreeMap<>();
Map<YearMonth, BigDecimal> 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<TableData.Column> 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<Map<String, Object>> 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<Integer> 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<Integer> 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);
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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."));
}
}
@@ -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<Integer> 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<QRecord> allStages(QQueryFilter filter) throws QException
{
return (dictionary(STAGE, filter));
}
/*******************************************************************************
** Read small settings dictionaries in deterministic keyset pages.
*******************************************************************************/
private List<QRecord> dictionary(String table, QQueryFilter filter) throws QException
{
List<QRecord> stages = new ArrayList<>();
int lastId = 0;
while(true)
{
QQueryFilter page = filter.clone().withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId));
List<QRecord> 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<QRecord> pipelines = dictionary("crm_pipeline", equalsFilter("isActive", true));
List<Map<String, Object>> 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<QRecord> stages = allStages(equalsFilter("pipelineId", pipelineId));
stages.sort(Comparator.comparing(row -> Objects.requireNonNullElse(row.getValueInteger("sortOrder"), 0)));
GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, "pipelineStageId");
Map<Serializable, BigDecimal[]> 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<Map<String, Object>> 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<QRecord> candidates = new ArrayList<>();
Map<Integer, String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<AggregateResult> results = aggregate(DEAL, filter, List.of(COUNT, AMOUNT), List.of(owner));
results.sort(Comparator.comparing((AggregateResult result) -> number(result.getAggregateValue(AMOUNT))).reversed());
List<Map<String, Object>> 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<Serializable, BigDecimal[]> groupedTotals(QQueryFilter filter, GroupBy group) throws QException
{
List<GroupBy> groups = new ArrayList<>(List.of(CURRENCY));
if(group != null)
{
groups.add(group);
}
Map<Serializable, BigDecimal[]> 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<AggregateResult> 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<AggregateResult> aggregate(String table, QQueryFilter filter, List<Aggregate> aggregates, List<GroupBy> 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<QRecord> 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<String, Object> 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 ("<a href=\"" + escape(href) + "\">" + escape(Objects.toString(label, "")) + "</a>");
}
/*******************************************************************************
** Escape text and attributes in links.
*******************************************************************************/
private static String escape(String value)
{
return (value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Three columns, full-width metadata, and the standard paginated table.
*******************************************************************************/
static TableData table(List<Map<String, Object>> 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));
}
}
@@ -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<MetaDataProducerMultiOutput>
{
public static final List<String> 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<String> 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<String> 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<String> 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);
}
}
@@ -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<Integer, Integer> enteredByStage = new HashMap<>();
Map<Integer, Integer> 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<Integer, String> stageNames = new HashMap<>();
for(QRecord record : stageOutput.getRecords())
{
stageNames.put(record.getValueInteger("id"), record.getValueString("name"));
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> 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<Map<String, Object>> rows = new ArrayList<>();
for(Map.Entry<Integer, Integer> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer, String> 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<Integer, Integer> countByStage = new LinkedHashMap<>();
Map<Integer, BigDecimal> totalAmountByStage = new LinkedHashMap<>();
Map<Integer, BigDecimal> 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<TableData.Column> 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<Map<String, Object>> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<MultiStatisticsData.StatisticsGroupData> 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<Integer> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<TableData.Column> 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<Map<String, Object>> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer, PipelineStage> 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<TableData.Column> 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<Map<String, Object>> 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<TableData.Column> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer> 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<TableData.Column> columns = List.of(
new TableData.Column("html", "Metric", "metric", "1fr", null),
new TableData.Column("html", "Value", "value", "1fr", "right")
);
List<Map<String, Object>> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer> closedWonStageIds = stageQuery.getRecords().stream()
.map(r -> r.getValueInteger("id"))
.toList();
///////////////////////////////////////////////////////////
// query deals in closed-won stages //
///////////////////////////////////////////////////////////
Map<String, BigDecimal> 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<TableData.Column> 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<Map.Entry<String, BigDecimal>> sorted = new ArrayList<>(revenueByOwner.entrySet());
sorted.sort(Comparator.comparing(Map.Entry<String, BigDecimal>::getValue).reversed());
List<Map<String, Object>> rows = new ArrayList<>();
int rank = 1;
for(Map.Entry<String, BigDecimal> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer, List<Integer>> 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<Integer, String> stageNames = new HashMap<>();
for(QRecord record : stageOutput.getRecords())
{
stageNames.put(record.getValueInteger("id"), record.getValueString("name"));
}
///////////////////////////
// build table columns //
///////////////////////////
List<TableData.Column> 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<Map<String, Object>> rows = new ArrayList<>();
for(Map.Entry<Integer, List<Integer>> entry : durationsByStage.entrySet())
{
Integer stageId = entry.getKey();
List<Integer> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -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<Integer> closedWonStageIds = new HashSet<>();
Set<Integer> 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<YearMonth, int[]> 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<TableData.Column> 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<Map<String, Object>> rows = new ArrayList<>();
for(Map.Entry<YearMonth, int[]> 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));
}
}
@@ -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<QWidgetMetaData>
{
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));
}
}
@@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
probability_override_pct INTEGER,
weighted_amount DECIMAL(20,6),
company_id INTEGER,
primary_contact_id INTEGER,
owner_user_id TEXT NOT NULL,
lead_source_id INTEGER,
expected_close_date DATE,
@@ -279,6 +280,7 @@ CREATE TABLE IF NOT EXISTS crm_deal (
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_id ON crm_deal (pipeline_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_pipeline_stage_id ON crm_deal (pipeline_stage_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_primary_contact_id ON crm_deal (primary_contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_company_id ON crm_deal (company_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_owner_user_id ON crm_deal (owner_user_id);
CREATE INDEX IF NOT EXISTS ix_crm_deal_lead_source_id ON crm_deal (lead_source_id);
@@ -382,6 +384,7 @@ CREATE INDEX IF NOT EXISTS ix_crm_email_list_member_form_submission_id ON crm_em
CREATE TABLE IF NOT EXISTS crm_email_message (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
activity_id INTEGER,
contact_id INTEGER,
from_address VARCHAR(250) NOT NULL,
from_name VARCHAR(100),
to_addresses TEXT NOT NULL,
@@ -408,6 +411,7 @@ CREATE TABLE IF NOT EXISTS crm_email_message (
modify_date TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_contact_id ON crm_email_message (contact_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_activity_id ON crm_email_message (activity_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_thread_id ON crm_email_message (thread_id);
CREATE INDEX IF NOT EXISTS ix_crm_email_message_email_template_id ON crm_email_message (email_template_id);
@@ -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<String, Set<String>> 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<String> 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);
}
}
@@ -49,7 +49,7 @@ class CrmPermissionsTest extends BaseTest
assertThat(PermissionsHelper.hasTablePermission(input, "crm_contact", TablePermissionSubType.EDIT)).isFalse();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.READ)).isFalse();
assertThat(PermissionsHelper.hasProcessPermission(input, "crm_gdpr_anonymize_contact")).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(input, "crm_activity_feed")).isFalse();
assertThat(PermissionsHelper.hasWidgetPermission(input, "crm_recent_web_leads")).isFalse();
}
@@ -62,7 +62,7 @@ class CrmPermissionsTest extends BaseTest
{
QueryInput input = new QueryInput("crm_contact");
QContext.getQSession().setPermissions(Set.of(CrmPermissions.APP_ACCESS, CrmPermissions.MANAGE_READ, CrmPermissions.MANAGE_WRITE, CrmPermissions.MANAGE));
assertThat(PermissionsHelper.hasWidgetPermission(input, "crm_activity_feed")).isTrue();
assertThat(PermissionsHelper.hasWidgetPermission(input, "crm_recent_web_leads")).isTrue();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.EDIT)).isTrue();
assertThat(PermissionsHelper.hasProcessPermission(input, "crm_merge_contacts")).isTrue();
assertThat(PermissionsHelper.hasTablePermission(input, "crm_currency", TablePermissionSubType.READ)).isTrue();
@@ -44,6 +44,7 @@ import com.kingsrook.qbits.crm.api.CrmIntakeApi;
import com.kingsrook.qbits.crm.api.CrmMarketingApi;
import com.kingsrook.qbits.crm.api.FormType;
import com.kingsrook.qbits.crm.security.CrmPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -54,6 +55,9 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat
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.widgets.RenderWidgetInput;
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.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -334,4 +338,36 @@ class CrmRdbmsTest
.collect(java.util.stream.Collectors.joining("\n")));
}
/*******************************************************************************
** Home sums and GROUP BY execute against the shipped SQL schema, too.
*******************************************************************************/
@Test
void testHomeAggregates() throws Exception
{
try(Statement statement = connection.createStatement())
{
statement.executeUpdate("INSERT INTO crm_pipeline (id, name) VALUES (1, 'Sales')");
statement.executeUpdate("INSERT INTO crm_pipeline_stage (id, pipeline_id, name, sort_order, probability_pct, is_closed_won, is_closed_lost) VALUES (1, 1, 'Open', 1, 50, FALSE, FALSE), (2, 1, 'Won', 2, 100, TRUE, FALSE)");
statement.executeUpdate("INSERT INTO crm_deal (name, pipeline_id, pipeline_stage_id, owner_user_id, amount_in_base_currency, weighted_amount, expected_close_date, actual_close_date) VALUES ('Open deal', 1, 1, 'Ada', 123.45, 61.73, CURRENT_DATE, NULL), ('Won deal', 1, 2, 'Ada', 20.25, 20.25, NULL, CURRENT_DATE)");
}
RenderWidgetAction renderer = new RenderWidgetAction();
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(qInstance.getWidget("crm_open_pipeline"));
StatisticsData statistics = (StatisticsData) renderer.execute(input).getWidgetData();
assertThat(new BigDecimal(statistics.getCount().toString())).isEqualByComparingTo("123.45");
input.setWidgetMetaData(qInstance.getWidget("crm_pipeline_summary"));
TableData pipeline = (TableData) renderer.execute(input).getWidgetData();
assertThat(pipeline.getRows()).hasSize(2);
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open (1 deals)</a>");
input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast"));
TableData forecast = (TableData) renderer.execute(input).getWidgetData();
assertThat(forecast.getRows()).hasSize(6);
assertThat(forecast.getRows().getFirst().get("second").toString()).endsWith(">1</a>");
input.setWidgetMetaData(qInstance.getWidget("crm_sales_leaderboard"));
TableData leaders = (TableData) renderer.execute(input).getWidgetData();
assertThat(leaders.getRows()).hasSize(1);
assertThat(leaders.getRows().getFirst().get("first").toString()).endsWith(">Ada</a>");
}
}
@@ -76,14 +76,14 @@ class ContactChildTableTest extends BaseTest
/*******************************************************************************
** Verify the join from Contact to DealContact exists in the QInstance.
** Verify the primary-contact join from Contact to Deal exists in the QInstance.
*******************************************************************************/
@Test
void testContactDealContactJoinExists()
void testContactPrimaryDealJoinExists()
{
String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, DealContact.TABLE_NAME).replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT);
String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, Deal.TABLE_NAME).replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT);
QJoinMetaData join = QContext.getQInstance().getJoin(joinName);
assertNotNull(join, "Expected join from Contact to DealContact to exist");
assertNotNull(join, "Expected join from Contact to Deal to exist");
}
@@ -43,6 +43,8 @@ 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.data.QRecord;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -66,6 +68,10 @@ class MergeContactsProcessTest extends BaseTest
///////////////////////////////////////
Integer primaryId = insertContact("primary@example.com", "Primary", "Contact");
Integer secondaryId = insertContact("secondary@example.com", "Secondary", "Contact");
MemoryRecordStore.getInstance().insert(new InsertInput("crm_deal")
.withRecord(new QRecord().withValue("id", 1).withValue("primaryContactId", secondaryId)), true);
MemoryRecordStore.getInstance().insert(new InsertInput("crm_email_message")
.withRecord(new QRecord().withValue("id", 1).withValue("contactId", secondaryId)), true);
///////////////////////////////////////
// create activity on secondary //
@@ -103,6 +109,8 @@ class MergeContactsProcessTest extends BaseTest
.withFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, primaryId))));
assertEquals(1, activities.getRecords().size());
assertEquals(primaryId, GetAction.execute("crm_deal", 1).getValueInteger("primaryContactId"));
assertEquals(primaryId, GetAction.execute("crm_email_message", 1).getValueInteger("contactId"));
}
@@ -104,6 +104,7 @@ class CloneDealProcessTest extends BaseTest
assertEquals("Big Deal (Copy)", newDeal.getName());
assertEquals(stage1Id, newDeal.getPipelineStageId()); // reset to first stage
assertEquals(pipelineId, newDeal.getPipelineId());
assertEquals(42, newDeal.getPrimaryContactId());
assertEquals(0, new BigDecimal("10000").compareTo(newDeal.getAmount()));
/////////////////////////////////////////////
@@ -164,6 +165,7 @@ class CloneDealProcessTest extends BaseTest
new InsertInput(Deal.TABLE_NAME).withRecordEntity(
new Deal()
.withName(name)
.withPrimaryContactId(42)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(amount)
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.crm.deals.model.DealContact;
import com.kingsrook.qbits.crm.deals.model.Pipeline;
import com.kingsrook.qbits.crm.deals.model.PipelineStage;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -50,6 +51,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -143,6 +145,9 @@ class ManageContactOnDealProcessTest extends BaseTest
assertEquals(1, primaryCount, "Only one contact should be primary");
assertEquals(contact3Id, primaryContactId, "Third contact should be the primary");
assertEquals(contact3Id, GetAction.execute(Deal.TABLE_NAME, dealId).getValueInteger("primaryContactId"));
runManageContact(dealId, contact3Id, "REMOVE", null);
assertNull(GetAction.execute(Deal.TABLE_NAME, dealId).getValueInteger("primaryContactId"));
}
@@ -102,6 +102,7 @@ class SendEmailProcessTest extends BaseTest
EmailMessage emailMsg = new EmailMessage(emailQuery.getRecords().get(0));
assertEquals("Test Subject", emailMsg.getSubject());
assertEquals(contactId, emailMsg.getContactId());
assertEquals("<p>Hello</p>", emailMsg.getBodyHtml());
assertEquals("jane@example.com", emailMsg.getToAddresses());
assertNotNull(emailMsg.getSentDate());
@@ -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)));
}
}
@@ -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<Map<String, Object>> rows = tableData.getRows();
assertEquals(3, rows.size());
/////////////////////////////////////////////////////////////
// verify columns are present in at least the first row //
/////////////////////////////////////////////////////////////
Map<String, Object> 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)));
}
}
@@ -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")));
}
}
@@ -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)));
}
}
@@ -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<Map<String, Object>> rows = tableData.getRows();
//////////////////////////////////////////////////////////////
// should have 2 months (Jan and Mar), not Feb (closed) //
//////////////////////////////////////////////////////////////
assertEquals(2, rows.size());
///////////////////////////////////////////
// verify Jan grouping //
///////////////////////////////////////////
Map<String, Object> janRow = rows.get(0);
assertEquals("2026-01", janRow.get("month"));
assertEquals(2, janRow.get("dealCount"));
///////////////////////////////////////////
// verify Mar grouping //
///////////////////////////////////////////
Map<String, Object> 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())));
}
}
@@ -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", "<script>").withValue("lastName", "A&B")
.withValue("createDate", Instant.now()));
TableData data = (TableData) render("crm_recent_web_leads");
assertThat(data.getRows()).hasSize(10);
assertCell(data, 0, "first", "&lt;script&gt; A&amp;B");
assertCell(data, 1, "first", "lead1@example.test");
assertCell(data, 1, "second", "Demo Request");
assertCell(data, 1, "third", "Spam");
assertLinks(data);
}
/*******************************************************************************
** Each current goal uses its owner, inclusive period and optional pipeline.
*******************************************************************************/
@Test
void goals() throws Exception
{
seedStages();
LocalDate today = LocalDate.now(ZoneOffset.UTC);
deal(1, 2, "250.25", "250.25", today, TEST_USER_ID);
deal(2, 2, "900", "900", today, "other");
seed("crm_sales_goal", new QRecord().withValue("userId", TEST_USER_ID).withValue("pipelineId", 1)
.withValue("periodStart", today).withValue("periodEnd", today).withValue("targetAmount", new BigDecimal("500.50")));
seed("crm_sales_goal", new QRecord().withValue("userId", "other").withValue("periodStart", today.minusDays(5))
.withValue("periodEnd", today.minusDays(1)).withValue("targetAmount", new BigDecimal("1")));
TableData data = (TableData) render("crm_goals");
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isTrue();
QContext.getQSession().setPermissions(java.util.Set.of());
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), "crm_goals")).isFalse();
assertThat(data.getRows()).hasSize(1);
assertCell(data, 0, "first", TEST_USER_ID + " (50.0%)");
assertCell(data, 0, "second", "500.50");
assertCell(data, 0, "third", "250.25");
assertLinks(data);
}
/*******************************************************************************
** Quarter boundaries, decimal revenue, owner grouping and ordering.
*******************************************************************************/
@Test
void leaderboard() throws Exception
{
seedStages();
LocalDate today = LocalDate.now(ZoneOffset.UTC);
LocalDate quarter = today.withMonth(((today.getMonthValue() - 1) / 3) * 3 + 1).withDayOfMonth(1);
deal(1, 2, "125.50", "125.50", today, "Ada");
deal(2, 2, "125.50", "125.50", quarter, "Ada");
deal(3, 2, "10", "10", today, "Bob");
deal(4, 2, "9999", "9999", quarter.minusDays(1), "Bob");
deal(5, 1, "9999", "9999", today, "Bob");
TableData data = (TableData) render("crm_sales_leaderboard");
assertThat(data.getRows()).hasSize(2);
assertCell(data, 0, "first", "Ada");
assertCell(data, 0, "second", "2");
assertCell(data, 0, "third", "251.00");
assertLinks(data);
}
/*******************************************************************************
** Record locks apply to aggregate totals and ordinary table queries alike.
*******************************************************************************/
@Test
void sessionLocks() throws Exception
{
seedStages();
seed("crm_deal", new QRecord().withValue("id", 1).withValue("pipelineStageId", 1).withValue("ownerUserId", TEST_USER_ID)
.withValue("amountInBaseCurrency", new BigDecimal("12.34")).withValue("stageEnteredDate", Instant.now().minus(30, ChronoUnit.DAYS)));
QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("owner"));
QContext.getQInstance().getTable("crm_deal").withRecordSecurityLock(new RecordSecurityLock()
.withFieldName("ownerUserId").withSecurityKeyType("owner").withNullValueBehavior(RecordSecurityLock.NullValueBehavior.DENY));
assertStatistic("crm_open_pipeline", "0");
assertThat(((TableData) render("crm_rotting_deals")).getRows()).isEmpty();
QContext.getQSession().withSecurityKeyValue("owner", TEST_USER_ID);
assertStatistic("crm_open_pipeline", "12.34");
assertThat(((TableData) render("crm_rotting_deals")).getRows()).hasSize(1);
}
/*******************************************************************************
** Seed stable stage IDs without invoking unrelated business customizers.
*******************************************************************************/
private void seedStages() throws QException
{
seed("crm_pipeline", new QRecord().withValue("id", 1).withValue("isActive", true));
for(int id = 1; id <= 3; id++)
{
seed("crm_pipeline_stage", new QRecord().withValue("id", id).withValue("pipelineId", 1)
.withValue("name", List.of("Open", "Won", "Lost").get(id - 1)).withValue("sortOrder", id)
.withValue("isClosedWon", id == 2).withValue("isClosedLost", id == 3).withValue("rotDays", 5));
}
}
/*******************************************************************************
** Direct fixtures preserve dates and computed amounts under test.
*******************************************************************************/
static void seed(String table, QRecord record) throws QException
{
MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecord(record), true);
}
/*******************************************************************************
** A deal with both expected and actual close dates for boundary tests.
*******************************************************************************/
private void deal(int id, int stage, String amount, String weighted, LocalDate date, String owner) throws QException
{
seed("crm_deal", new QRecord().withValue("id", id).withValue("name", "Deal " + id).withValue("pipelineId", 1)
.withValue("pipelineStageId", stage).withValue("amountInBaseCurrency", new BigDecimal(amount))
.withValue("weightedAmount", new BigDecimal(weighted)).withValue("actualCloseDate", date).withValue("expectedCloseDate", date)
.withValue("ownerUserId", owner));
}
/*******************************************************************************
** Activity fixture.
*******************************************************************************/
private void activity(String owner, boolean completed, Instant due) throws QException
{
seed("crm_activity", new QRecord().withValue("assignedToUserId", owner).withValue("isCompleted", completed).withValue("dueDate", due));
}
/*******************************************************************************
** Exercise metadata lookup and the standard render action.
*******************************************************************************/
static QWidgetData render(String name) throws QException
{
return (new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(QContext.getQInstance().getWidget(name))).getWidgetData());
}
/*******************************************************************************
** Assert decimal statistics exactly, independent of BigDecimal scale.
*******************************************************************************/
private void assertStatistic(String name, String expected) throws QException
{
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString())).isEqualByComparingTo(expected);
}
/*******************************************************************************
** Standard HTML cell with one linked label.
*******************************************************************************/
static void assertCell(TableData data, int index, String column, String text)
{
assertThat(data.getRows().get(index).get(column).toString()).endsWith(">" + text + "</a>");
}
/*******************************************************************************
** Every visible cell links to a record or a filtered record list.
*******************************************************************************/
private void assertLinks(TableData data)
{
for(Map<String, Object> row : data.getRows())
{
assertThat(row.values()).allSatisfy(value -> assertThat(value.toString()).startsWith("<a href=\"/app/crm_").endsWith("</a>"));
}
}
}
@@ -1,148 +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 CrmPipelineConversionWidget.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
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.assertNotNull;
/*******************************************************************************
** Tests for the Pipeline Conversion widget.
*******************************************************************************/
class CrmPipelineConversionWidgetTest extends BaseTest
{
/*******************************************************************************
** Insert stage history data and render widget.
*******************************************************************************/
@Test
void testRenderPipelineConversion() throws QException
{
///////////////////////////////////////
// set up pipeline and stages //
///////////////////////////////////////
Integer pipelineId = insertPipeline("Sales");
Integer stage1Id = insertPipelineStage(pipelineId, "Prospect", 1);
Integer stage2Id = insertPipelineStage(pipelineId, "Qualified", 2);
Integer stage3Id = insertPipelineStage(pipelineId, "Closed", 3);
///////////////////////////////////////
// insert stage transitions //
///////////////////////////////////////
insertStageHistory(1, null, stage1Id);
insertStageHistory(1, stage1Id, stage2Id);
insertStageHistory(2, null, stage1Id);
insertStageHistory(2, stage1Id, stage2Id);
insertStageHistory(2, stage2Id, stage3Id);
///////////////////////////////////////
// render the widget //
///////////////////////////////////////
CrmPipelineConversionRenderer renderer = new CrmPipelineConversionRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmPipelineConversionWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
assertNotNull(widgetOutput);
assertNotNull(widgetOutput.getWidgetData());
assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
assertThat(rows).isNotEmpty();
Map<String, Object> firstRow = rows.get(0);
assertThat(firstRow).containsKey("stageName");
assertThat(firstRow).containsKey("entered");
assertThat(firstRow).containsKey("progressed");
assertThat(firstRow).containsKey("conversionRate");
}
/*******************************************************************************
** 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) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(
new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(sortOrder * 25)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/*******************************************************************************
** Insert Stage History.
*******************************************************************************/
private void insertStageHistory(Integer dealId, Integer fromStageId, Integer toStageId) throws QException
{
new InsertAction().execute(
new InsertInput(DealStageHistory.TABLE_NAME).withRecordEntity(
new DealStageHistory()
.withDealId(dealId)
.withFromStageId(fromStageId)
.withToStageId(toStageId)
.withUserId("user-001")
.withTransitionDate(Instant.now())));
}
}
@@ -1,206 +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 CrmPipelineSummaryWidget.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashMap;
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 Pipeline Summary widget renderer.
*******************************************************************************/
class CrmPipelineSummaryWidgetTest extends BaseTest
{
/*******************************************************************************
** Create deals in various stages, render, verify counts and amounts.
*******************************************************************************/
@Test
void testRenderPipelineSummary() throws QException
{
///////////////////////////////////////////
// set up pipeline and stages //
///////////////////////////////////////////
Integer pipelineId = insertPipeline("Sales Pipeline");
Integer stage1Id = insertStage(pipelineId, "Qualification", 1, 10);
Integer stage2Id = insertStage(pipelineId, "Proposal", 2, 50);
Integer stage3Id = insertStage(pipelineId, "Closed Won", 3, 100);
///////////////////////////////////////////
// insert deals in various stages //
///////////////////////////////////////////
insertDeal("Deal A", pipelineId, stage1Id, new BigDecimal("10000"), new BigDecimal("1000"));
insertDeal("Deal B", pipelineId, stage1Id, new BigDecimal("5000"), new BigDecimal("500"));
insertDeal("Deal C", pipelineId, stage2Id, new BigDecimal("20000"), new BigDecimal("10000"));
///////////////////////////////////////////
// render the widget //
///////////////////////////////////////////
CrmPipelineSummaryRenderer renderer = new CrmPipelineSummaryRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmPipelineSummaryWidgetProducer().produce(null));
widgetInput.setQueryParams(new HashMap<>(Map.of("pipelineId", String.valueOf(pipelineId))));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
assertNotNull(widgetOutput);
assertNotNull(widgetOutput.getWidgetData());
assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
///////////////////////////////////////////
// three stages should appear //
///////////////////////////////////////////
assertEquals(3, rows.size());
///////////////////////////////////////////
// verify stage 1 counts //
///////////////////////////////////////////
Map<String, Object> qualRow = rows.get(0);
assertEquals("Qualification", qualRow.get("stageName"));
assertEquals(2, qualRow.get("dealCount"));
///////////////////////////////////////////
// verify stage 2 counts //
///////////////////////////////////////////
Map<String, Object> propRow = rows.get(1);
assertEquals("Proposal", propRow.get("stageName"));
assertEquals(1, propRow.get("dealCount"));
///////////////////////////////////////////
// verify stage 3 has zero deals //
///////////////////////////////////////////
Map<String, Object> wonRow = rows.get(2);
assertEquals("Closed Won", wonRow.get("stageName"));
assertEquals(0, wonRow.get("dealCount"));
}
/*******************************************************************************
** Render widget with no deals and verify empty stage rows still appear.
*******************************************************************************/
@Test
void testRenderEmptyPipeline() throws QException
{
Integer pipelineId = insertPipeline("Empty Pipeline");
insertStage(pipelineId, "Stage A", 1, 10);
insertStage(pipelineId, "Stage B", 2, 50);
CrmPipelineSummaryRenderer renderer = new CrmPipelineSummaryRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmPipelineSummaryWidgetProducer().produce(null));
widgetInput.setQueryParams(new HashMap<>(Map.of("pipelineId", String.valueOf(pipelineId))));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
assertEquals(2, tableData.getRows().size());
///////////////////////////////////////////
// both stages should have zero deals //
///////////////////////////////////////////
assertEquals(0, tableData.getRows().get(0).get("dealCount"));
assertEquals(0, tableData.getRows().get(1).get("dealCount"));
}
/***************************************************************************
** 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) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME)
.withRecordEntity(new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(probabilityPct)
.withIsClosedWon(false)
.withIsClosedLost(false)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/***************************************************************************
** Helper: insert a Deal.
***************************************************************************/
private void insertDeal(String name, Integer pipelineId, Integer stageId,
BigDecimal amount, BigDecimal weightedAmount) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME)
.withRecordEntity(new Deal()
.withName(name)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(amount)
.withWeightedAmount(weightedAmount)
.withOwnerUserId("user-001")
.withStageEnteredDate(Instant.now())));
}
}
@@ -1,241 +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 CrmQuotaAttainmentRenderer.
*******************************************************************************/
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.core.model.enums.CrmGoalPeriod;
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.qbits.crm.scoring.model.SalesGoal;
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;
/*******************************************************************************
** Tests for the Quota Attainment widget: no goal, with goal, with goal and deals.
*******************************************************************************/
class CrmQuotaAttainmentWidgetTest extends BaseTest
{
/*******************************************************************************
** Test render with no goal shows empty state.
*******************************************************************************/
@Test
void testRenderNoGoal() throws QException
{
CrmQuotaAttainmentRenderer renderer = new CrmQuotaAttainmentRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmQuotaAttainmentWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
assertThat(output.getWidgetData()).isInstanceOf(MultiStatisticsData.class);
MultiStatisticsData data = (MultiStatisticsData) output.getWidgetData();
assertThat(data.getStatisticsGroupData()).isNotEmpty();
assertThat(data.getStatisticsGroupData().get(0).getHeader()).isEqualTo("No Goal Set");
}
/*******************************************************************************
** Test render with goal but no deals.
*******************************************************************************/
@Test
void testRenderWithGoalNoDeal() throws QException
{
Integer pipelineId = insertPipeline("Sales");
insertPipelineStage(pipelineId, "Won", 3, true, false);
insertSalesGoal(TEST_USER_ID, pipelineId, new BigDecimal("100000"));
CrmQuotaAttainmentRenderer renderer = new CrmQuotaAttainmentRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmQuotaAttainmentWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
MultiStatisticsData data = (MultiStatisticsData) output.getWidgetData();
assertThat(data.getStatisticsGroupData()).isNotEmpty();
assertThat(data.getStatisticsGroupData().get(0).getHeader()).isEqualTo("Quota Attainment");
}
/*******************************************************************************
** Test render with goal and deals.
*******************************************************************************/
@Test
void testRenderWithGoalAndDeals() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer wonStageId = insertPipelineStage(pipelineId, "Won", 3, true, false);
insertSalesGoal(TEST_USER_ID, pipelineId, new BigDecimal("100000"));
insertDeal("Deal A", pipelineId, wonStageId, new BigDecimal("25000"));
insertDeal("Deal B", pipelineId, wonStageId, new BigDecimal("25000"));
CrmQuotaAttainmentRenderer renderer = new CrmQuotaAttainmentRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmQuotaAttainmentWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
MultiStatisticsData data = (MultiStatisticsData) output.getWidgetData();
assertThat(data.getStatisticsGroupData()).isNotEmpty();
assertThat(data.getStatisticsGroupData().get(0).getHeader()).isEqualTo("Quota Attainment");
// 50000 / 100000 = 50%
assertThat(data.getStatisticsGroupData().get(0).getStatisticList()).hasSize(3);
}
/*******************************************************************************
** Test render with goal without pipeline filter.
*******************************************************************************/
@Test
void testRenderWithGoalNoPipelineFilter() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer wonStageId = insertPipelineStage(pipelineId, "Won", 3, true, false);
insertSalesGoalNoPipeline(TEST_USER_ID, new BigDecimal("50000"));
insertDeal("Deal C", pipelineId, wonStageId, new BigDecimal("10000"));
CrmQuotaAttainmentRenderer renderer = new CrmQuotaAttainmentRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmQuotaAttainmentWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
MultiStatisticsData data = (MultiStatisticsData) output.getWidgetData();
assertThat(data.getStatisticsGroupData()).isNotEmpty();
assertThat(data.getStatisticsGroupData().get(0).getHeader()).isEqualTo("Quota Attainment");
}
/*******************************************************************************
** 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 Sales Goal.
*******************************************************************************/
private void insertSalesGoal(String userId, Integer pipelineId, BigDecimal target) throws QException
{
new InsertAction().execute(
new InsertInput(SalesGoal.TABLE_NAME).withRecordEntity(
new SalesGoal()
.withUserId(userId)
.withPipelineId(pipelineId)
.withPeriodType(CrmGoalPeriod.MONTHLY.getId())
.withPeriodStart(LocalDate.now().withDayOfMonth(1))
.withPeriodEnd(LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1))
.withTargetAmount(target)
.withCurrencyCode("USD")));
}
/*******************************************************************************
** Insert Sales Goal No Pipeline.
*******************************************************************************/
private void insertSalesGoalNoPipeline(String userId, BigDecimal target) throws QException
{
new InsertAction().execute(
new InsertInput(SalesGoal.TABLE_NAME).withRecordEntity(
new SalesGoal()
.withUserId(userId)
.withPeriodType(CrmGoalPeriod.MONTHLY.getId())
.withPeriodStart(LocalDate.now().withDayOfMonth(1))
.withPeriodEnd(LocalDate.now().withDayOfMonth(1).plusMonths(1).minusDays(1))
.withTargetAmount(target)
.withCurrencyCode("USD")));
}
/*******************************************************************************
** Insert Deal.
*******************************************************************************/
private void insertDeal(String name, Integer pipelineId, Integer stageId,
BigDecimal amountInBase) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME).withRecordEntity(
new Deal()
.withName(name)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(amountInBase)
.withAmountInBaseCurrency(amountInBase)
.withActualCloseDate(LocalDate.now())
.withOwnerUserId(TEST_USER_ID)));
}
}
@@ -1,177 +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 CrmRecentDealsWidget.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
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 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 Recent Deals widget.
*******************************************************************************/
class CrmRecentDealsWidgetTest extends BaseTest
{
/*******************************************************************************
** Insert closed deals, render widget, verify rows.
*******************************************************************************/
@Test
void testRenderRecentDeals() throws QException
{
///////////////////////////////////////
// set up pipeline and stage //
///////////////////////////////////////
Integer pipelineId = insertPipeline("Sales");
Integer stageId = insertPipelineStage(pipelineId, "Closed Won", 1);
///////////////////////////////////////
// insert deals with close dates //
///////////////////////////////////////
insertClosedDeal("Deal A", pipelineId, stageId, new BigDecimal("10000"), LocalDate.of(2026, 3, 1));
insertClosedDeal("Deal B", pipelineId, stageId, new BigDecimal("25000"), LocalDate.of(2026, 3, 15));
insertClosedDeal("Deal C", pipelineId, stageId, new BigDecimal("5000"), LocalDate.of(2026, 3, 20));
///////////////////////////////////////
// render the widget //
///////////////////////////////////////
CrmRecentDealsRenderer renderer = new CrmRecentDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRecentDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
assertNotNull(widgetOutput);
assertNotNull(widgetOutput.getWidgetData());
assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
assertEquals(3, rows.size());
/////////////////////////////////////////////////////////////
// verify ordering: most recent first //
/////////////////////////////////////////////////////////////
assertEquals("Deal C", rows.get(0).get("name"));
assertEquals("Deal B", rows.get(1).get("name"));
assertEquals("Deal A", rows.get(2).get("name"));
/////////////////////////////////////////////////////////////
// verify columns present //
/////////////////////////////////////////////////////////////
assertThat(rows.get(0)).containsKey("name");
assertThat(rows.get(0)).containsKey("amount");
assertThat(rows.get(0)).containsKey("ownerUserId");
assertThat(rows.get(0)).containsKey("closedDate");
assertThat(rows.get(0)).containsKey("winLossReason");
}
/*******************************************************************************
** Render with no deals returns empty rows.
*******************************************************************************/
@Test
void testRenderEmptyRecentDeals() throws QException
{
CrmRecentDealsRenderer renderer = new CrmRecentDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRecentDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
assertThat(tableData.getRows()).isEmpty();
}
/*******************************************************************************
** 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) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(
new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(100)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/*******************************************************************************
** Insert Closed Deal.
*******************************************************************************/
private void insertClosedDeal(String name, Integer pipelineId, Integer stageId, BigDecimal amount, LocalDate closeDate) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME).withRecord(
new QRecord()
.withValue("name", name)
.withValue("pipelineId", pipelineId)
.withValue("pipelineStageId", stageId)
.withValue("amount", amount)
.withValue("actualCloseDate", closeDate)
.withValue("ownerUserId", "user-001")));
}
}
@@ -1,266 +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 CrmRottingDealsWidget.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
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 Rotting Deals widget renderer.
*******************************************************************************/
class CrmRottingDealsWidgetTest extends BaseTest
{
/*******************************************************************************
** Create deals past rot threshold and verify they appear in the widget.
*******************************************************************************/
@Test
void testRenderRottingDeals() throws QException
{
///////////////////////////////////////////
// set up pipeline with rot days //
///////////////////////////////////////////
Integer pipelineId = insertPipeline("Sales Pipeline");
Integer stageId = insertStage(pipelineId, "Qualification", 1, 10, 7);
/////////////////////////////////////////////////////////
// insert a deal that entered stage 15 days ago (rot=7) //
/////////////////////////////////////////////////////////
insertDealInStage("Stale Deal", pipelineId, stageId,
new BigDecimal("10000"), Instant.now().minus(15, ChronoUnit.DAYS));
/////////////////////////////////////////////////////////
// insert a deal that entered stage 3 days ago (ok) //
/////////////////////////////////////////////////////////
insertDealInStage("Fresh Deal", pipelineId, stageId,
new BigDecimal("5000"), Instant.now().minus(3, ChronoUnit.DAYS));
///////////////////////////////////////////
// render the widget //
///////////////////////////////////////////
CrmRottingDealsRenderer renderer = new CrmRottingDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRottingDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
assertNotNull(widgetOutput);
assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
///////////////////////////////////////////
// only the stale deal should appear //
///////////////////////////////////////////
assertEquals(1, rows.size());
assertEquals("Stale Deal", rows.get(0).get("dealName"));
assertEquals("Qualification", rows.get(0).get("stageName"));
assertEquals(7, rows.get(0).get("rotDays"));
int daysInStage = (int) rows.get(0).get("daysInStage");
assertThat(daysInStage).isGreaterThanOrEqualTo(15);
}
/*******************************************************************************
** Deals in stages without rotDays should not appear.
*******************************************************************************/
@Test
void testStagesWithoutRotDaysAreExcluded() throws QException
{
Integer pipelineId = insertPipeline("No Rot Pipeline");
Integer stageId = insertStageNoRot(pipelineId, "Open", 1, 10);
insertDealInStage("No Rot Deal", pipelineId, stageId,
new BigDecimal("1000"), Instant.now().minus(100, ChronoUnit.DAYS));
CrmRottingDealsRenderer renderer = new CrmRottingDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRottingDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
assertEquals(0, tableData.getRows().size());
}
/*******************************************************************************
** Render with no deals and verify empty output.
*******************************************************************************/
@Test
void testRenderEmptyRottingDeals() throws QException
{
CrmRottingDealsRenderer renderer = new CrmRottingDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRottingDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
assertEquals(0, tableData.getRows().size());
}
/*******************************************************************************
** Deals with multiple rot stages: only past-threshold ones appear.
*******************************************************************************/
@Test
void testMultipleStagesWithDifferentRotDays() throws QException
{
Integer pipelineId = insertPipeline("Multi Rot");
Integer stage1Id = insertStage(pipelineId, "Stage 5 Day Rot", 1, 10, 5);
Integer stage2Id = insertStage(pipelineId, "Stage 30 Day Rot", 2, 50, 30);
///////////////////////////////////////////
// deal in stage1, 10 days old (rot=5) //
///////////////////////////////////////////
insertDealInStage("Rotting 1", pipelineId, stage1Id,
new BigDecimal("8000"), Instant.now().minus(10, ChronoUnit.DAYS));
///////////////////////////////////////////
// deal in stage2, 10 days old (rot=30) //
///////////////////////////////////////////
insertDealInStage("Fresh 2", pipelineId, stage2Id,
new BigDecimal("12000"), Instant.now().minus(10, ChronoUnit.DAYS));
CrmRottingDealsRenderer renderer = new CrmRottingDealsRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmRottingDealsWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
///////////////////////////////////////////
// only Rotting 1 should appear //
///////////////////////////////////////////
assertEquals(1, rows.size());
assertEquals("Rotting 1", rows.get(0).get("dealName"));
}
/***************************************************************************
** 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 with rotDays.
***************************************************************************/
private Integer insertStage(Integer pipelineId, String name, Integer sortOrder,
Integer probabilityPct, Integer rotDays) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME)
.withRecordEntity(new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(probabilityPct)
.withIsClosedWon(false)
.withIsClosedLost(false)
.withRotDays(rotDays)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/***************************************************************************
** Helper: insert a PipelineStage without rotDays.
***************************************************************************/
private Integer insertStageNoRot(Integer pipelineId, String name, Integer sortOrder,
Integer probabilityPct) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME)
.withRecordEntity(new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(probabilityPct)
.withIsClosedWon(false)
.withIsClosedLost(false)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/***************************************************************************
** Helper: insert a Deal with a specific stageEnteredDate.
***************************************************************************/
private void insertDealInStage(String name, Integer pipelineId, Integer stageId,
BigDecimal amount, Instant stageEnteredDate) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME)
.withRecordEntity(new Deal()
.withName(name)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(amount)
.withOwnerUserId("user-001")
.withStageEnteredDate(stageEnteredDate)));
}
}
@@ -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.
*/
/*******************************************************************************
** Unit tests for CrmSalesCycleLengthRenderer.
*******************************************************************************/
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 Sales Cycle Length widget: with data and empty results.
*******************************************************************************/
class CrmSalesCycleLengthWidgetTest extends BaseTest
{
/*******************************************************************************
** Test render with closed-won deals computes cycle length.
*******************************************************************************/
@Test
void testRenderWithData() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer wonStageId = insertPipelineStage(pipelineId, "Won", 3, true, false);
insertDeal("Deal A", pipelineId, wonStageId, LocalDate.now().minusDays(30));
CrmSalesCycleLengthRenderer renderer = new CrmSalesCycleLengthRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmSalesCycleLengthWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
TableData tableData = (TableData) output.getWidgetData();
assertThat(tableData.getRows()).hasSize(2);
assertThat(tableData.getRows().get(0).get("metric")).isEqualTo("Avg Sales Cycle (days)");
assertThat(tableData.getRows().get(1).get("value")).isEqualTo("1");
}
/*******************************************************************************
** Test render with no data shows zero.
*******************************************************************************/
@Test
void testRenderEmpty() throws QException
{
CrmSalesCycleLengthRenderer renderer = new CrmSalesCycleLengthRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmSalesCycleLengthWidgetProducer().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");
}
/*******************************************************************************
** 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,
LocalDate closeDate) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME).withRecordEntity(
new Deal()
.withName(name)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(new BigDecimal("10000"))
.withActualCloseDate(closeDate)
.withOwnerUserId("user-001")));
}
}
@@ -1,149 +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 CrmSalesLeaderboardRenderer.
*******************************************************************************/
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 Sales Leaderboard widget.
*******************************************************************************/
class CrmSalesLeaderboardWidgetTest extends BaseTest
{
/*******************************************************************************
** Test render with data.
*******************************************************************************/
@Test
void testRenderWithData() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer wonStageId = insertPipelineStage(pipelineId, "Won", 3, true, false);
insertDeal("Deal A", pipelineId, wonStageId, new BigDecimal("30000"), "user-A");
insertDeal("Deal B", pipelineId, wonStageId, new BigDecimal("20000"), "user-B");
insertDeal("Deal C", pipelineId, wonStageId, new BigDecimal("10000"), "user-A");
CrmSalesLeaderboardRenderer renderer = new CrmSalesLeaderboardRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmSalesLeaderboardWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
TableData tableData = (TableData) output.getWidgetData();
assertThat(tableData.getRows()).hasSize(2);
// user-A should be first (40000 > 20000)
assertThat(tableData.getRows().get(0).get("owner")).isEqualTo("user-A");
assertThat(tableData.getRows().get(0).get("rank")).isEqualTo("1");
assertThat(tableData.getRows().get(1).get("owner")).isEqualTo("user-B");
}
/*******************************************************************************
** Test render with no data.
*******************************************************************************/
@Test
void testRenderEmpty() throws QException
{
CrmSalesLeaderboardRenderer renderer = new CrmSalesLeaderboardRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmSalesLeaderboardWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
TableData tableData = (TableData) output.getWidgetData();
assertThat(tableData.getRows()).isEmpty();
}
/*******************************************************************************
** 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 amountInBase, String ownerUserId) throws QException
{
new InsertAction().execute(
new InsertInput(Deal.TABLE_NAME).withRecordEntity(
new Deal()
.withName(name)
.withPipelineId(pipelineId)
.withPipelineStageId(stageId)
.withAmount(amountInBase)
.withAmountInBaseCurrency(amountInBase)
.withActualCloseDate(LocalDate.now())
.withOwnerUserId(ownerUserId)));
}
}
@@ -1,181 +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 CrmStageDurationRenderer.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
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.assertNotNull;
/*******************************************************************************
** Tests for the Stage Duration widget: with data, empty data, and null durations.
*******************************************************************************/
class CrmStageDurationWidgetTest extends BaseTest
{
/*******************************************************************************
** Test render with duration data produces rows.
*******************************************************************************/
@Test
void testRenderWithData() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer stage1Id = insertPipelineStage(pipelineId, "Prospect", 1);
Integer stage2Id = insertPipelineStage(pipelineId, "Qualified", 2);
// Insert history records with duration populated
insertStageHistory(1, stage1Id, stage2Id, 5);
insertStageHistory(2, stage1Id, stage2Id, 10);
insertStageHistory(3, stage1Id, stage2Id, 15);
CrmStageDurationRenderer renderer = new CrmStageDurationRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmStageDurationWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
assertNotNull(output);
assertNotNull(output.getWidgetData());
assertThat(output.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) output.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
assertThat(rows).isNotEmpty();
assertThat(rows.get(0)).containsKey("stageName");
assertThat(rows.get(0)).containsKey("avgDays");
assertThat(rows.get(0)).containsKey("medianDays");
}
/*******************************************************************************
** Test render with no data produces empty rows.
*******************************************************************************/
@Test
void testRenderEmptyData() throws QException
{
CrmStageDurationRenderer renderer = new CrmStageDurationRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmStageDurationWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
assertNotNull(output);
TableData tableData = (TableData) output.getWidgetData();
assertThat(tableData.getRows()).isEmpty();
}
/*******************************************************************************
** Test render with even number of durations for median calculation.
*******************************************************************************/
@Test
void testRenderWithEvenDurations() throws QException
{
Integer pipelineId = insertPipeline("Sales");
Integer stage1Id = insertPipelineStage(pipelineId, "Prospect", 1);
Integer stage2Id = insertPipelineStage(pipelineId, "Qualified", 2);
insertStageHistory(1, stage1Id, stage2Id, 4);
insertStageHistory(2, stage1Id, stage2Id, 8);
CrmStageDurationRenderer renderer = new CrmStageDurationRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmStageDurationWidgetProducer().produce(null));
RenderWidgetOutput output = renderer.render(widgetInput);
TableData tableData = (TableData) output.getWidgetData();
assertThat(tableData.getRows()).hasSize(1);
// median of [4, 8] = (4+8)/2 = 6
assertThat(tableData.getRows().get(0).get("medianDays")).isEqualTo(6);
}
/*******************************************************************************
** 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) throws QException
{
InsertOutput output = new InsertAction().execute(
new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(
new PipelineStage()
.withPipelineId(pipelineId)
.withName(name)
.withSortOrder(sortOrder)
.withProbabilityPct(sortOrder * 25)));
return (output.getRecords().get(0).getValueInteger("id"));
}
/*******************************************************************************
** Insert Stage History.
*******************************************************************************/
private void insertStageHistory(Integer dealId, Integer fromStageId, Integer toStageId, Integer durationDays) throws QException
{
new InsertAction().execute(
new InsertInput(DealStageHistory.TABLE_NAME).withRecordEntity(
new DealStageHistory()
.withDealId(dealId)
.withFromStageId(fromStageId)
.withToStageId(toStageId)
.withDurationInFromStageDays(durationDays)
.withUserId("user-001")
.withTransitionDate(Instant.now())));
}
}
@@ -1,175 +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 CrmWinRateWidget.
*******************************************************************************/
package com.kingsrook.qbits.crm.widgets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.crm.BaseTest;
import com.kingsrook.qbits.crm.deals.model.DealStageHistory;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Tests for the Win Rate widget.
*******************************************************************************/
class CrmWinRateWidgetTest extends BaseTest
{
/*******************************************************************************
** Insert won and lost deal transitions, render widget, verify output.
*******************************************************************************/
@Test
void testRenderWinRate() throws QException
{
///////////////////////////////////////
// set up pipeline and stages //
///////////////////////////////////////
Integer pipelineId = insertPipeline("Sales");
Integer prospectId = insertPipelineStage(pipelineId, "Prospect", 1, false, false);
Integer closedWonId = insertPipelineStage(pipelineId, "Closed Won", 2, true, false);
Integer closedLostId = insertPipelineStage(pipelineId, "Closed Lost", 3, false, true);
///////////////////////////////////////
// insert transitions //
///////////////////////////////////////
insertStageHistory(1, prospectId, closedWonId);
insertStageHistory(2, prospectId, closedWonId);
insertStageHistory(3, prospectId, closedLostId);
///////////////////////////////////////
// render the widget //
///////////////////////////////////////
CrmWinRateRenderer renderer = new CrmWinRateRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmWinRateWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
assertNotNull(widgetOutput);
assertNotNull(widgetOutput.getWidgetData());
assertThat(widgetOutput.getWidgetData()).isInstanceOf(TableData.class);
TableData tableData = (TableData) widgetOutput.getWidgetData();
List<Map<String, Object>> rows = tableData.getRows();
assertThat(rows).isNotEmpty();
Map<String, Object> firstRow = rows.get(0);
assertThat(firstRow).containsKey("month");
assertThat(firstRow).containsKey("won");
assertThat(firstRow).containsKey("lost");
assertThat(firstRow).containsKey("winRate");
//////////////////////////////////////////////
// verify 2 won, 1 lost => 66.7% win rate //
//////////////////////////////////////////////
int totalWon = (int) firstRow.get("won");
int totalLost = (int) firstRow.get("lost");
assertTrue(totalWon >= 2);
assertTrue(totalLost >= 1);
}
/*******************************************************************************
** Render with no data returns empty rows.
*******************************************************************************/
@Test
void testRenderEmptyWinRate() throws QException
{
CrmWinRateRenderer renderer = new CrmWinRateRenderer();
RenderWidgetInput widgetInput = new RenderWidgetInput();
widgetInput.setWidgetMetaData(new CrmWinRateWidgetProducer().produce(null));
RenderWidgetOutput widgetOutput = renderer.render(widgetInput);
TableData tableData = (TableData) widgetOutput.getWidgetData();
assertThat(tableData.getRows()).isEmpty();
}
/*******************************************************************************
** 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 Stage History.
*******************************************************************************/
private void insertStageHistory(Integer dealId, Integer fromStageId, Integer toStageId) throws QException
{
new InsertAction().execute(
new InsertInput(DealStageHistory.TABLE_NAME).withRecordEntity(
new DealStageHistory()
.withDealId(dealId)
.withFromStageId(fromStageId)
.withToStageId(toStageId)
.withUserId("user-001")
.withTransitionDate(Instant.now())));
}
}