fix(ui): labels, money formatting, contact layout and home polish #14
No files matched your search
@@ -179,8 +179,8 @@ CREATE TABLE IF NOT EXISTS crm_contact (
|
||||
last_touch_referrer VARCHAR(1000),
|
||||
last_touch_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
email VARCHAR(320),
|
||||
secondary_email VARCHAR(320),
|
||||
phone VARCHAR(50),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRM presentation shared by package-discovered tables and child widgets.
|
||||
*******************************************************************************/
|
||||
public final class CrmPresentation
|
||||
{
|
||||
private static final Map<String, String> LABELS = Map.ofEntries(
|
||||
Map.entry("crm_contact", "Contacts"),
|
||||
Map.entry("crm_company", "Companies"),
|
||||
Map.entry("crm_deal", "Deals"),
|
||||
Map.entry("crm_activity", "Activities"),
|
||||
Map.entry("crm_form_submission", "Web Leads"),
|
||||
Map.entry("crm_email_list", "Lists"),
|
||||
Map.entry("crm_pipeline", "Pipelines"),
|
||||
Map.entry("crm_pipeline_stage", "Pipeline Stages"),
|
||||
Map.entry("crm_product", "Products"),
|
||||
Map.entry("crm_email_template", "Email Templates"),
|
||||
Map.entry("crm_email_sequence", "Email Sequences"),
|
||||
Map.entry("crm_sequence_step", "Sequence Steps"),
|
||||
Map.entry("crm_sequence_enrollment", "Sequence Enrollments"),
|
||||
Map.entry("crm_lead_score_rule", "Lead Score Rules"),
|
||||
Map.entry("crm_assignment_rule", "Assignment Rules"),
|
||||
Map.entry("crm_assignment_rule_member", "Assignment Rule Members"),
|
||||
Map.entry("crm_sales_goal", "Sales Goals"),
|
||||
Map.entry("crm_currency", "Currencies"),
|
||||
Map.entry("crm_lifecycle_stage", "Lifecycle Stages"),
|
||||
Map.entry("crm_lead_source", "Lead Sources"),
|
||||
Map.entry("crm_industry", "Industries"),
|
||||
Map.entry("crm_activity_type", "Activity Types"),
|
||||
Map.entry("crm_activity_outcome", "Activity Outcomes"),
|
||||
Map.entry("crm_contact_role", "Contact Roles"),
|
||||
Map.entry("crm_win_loss_reason", "Win/Loss Reasons"),
|
||||
Map.entry("crm_consent_record", "Consent Records"),
|
||||
Map.entry("crm_email_list_member", "List Members"),
|
||||
Map.entry("crm_email_message", "Email Messages"),
|
||||
Map.entry("crm_deal_contact", "Deal Contacts"),
|
||||
Map.entry("crm_deal_product", "Deal Products"),
|
||||
Map.entry("crm_deal_stage_history", "Deal Stage History"),
|
||||
Map.entry("crm_activity_participant", "Activity Participants"),
|
||||
Map.entry("crm_external_mapping", "External Mappings"),
|
||||
Map.entry("crm_job_state", "Job State"));
|
||||
private static final Map<String, List<String>> CHILD_FIELDS = Map.ofEntries(
|
||||
Map.entry("crm_contact", List.of("firstName", "lastName", "email", "phone")),
|
||||
Map.entry("crm_deal", List.of("name", "pipelineStageId", "amount", "expectedCloseDate")),
|
||||
Map.entry("crm_activity", List.of("subject", "activityTypeId", "startDate")),
|
||||
Map.entry("crm_form_submission", List.of("formKey", "createDate", "conversionStatus")),
|
||||
Map.entry("crm_email_list_member", List.of("emailListId", "status")),
|
||||
Map.entry("crm_consent_record", List.of("consentType", "status", "consentDate")),
|
||||
Map.entry("crm_sequence_enrollment", List.of("sequenceId", "status", "enrolledDate")),
|
||||
Map.entry("crm_email_message", List.of("subject", "status", "sentDate")),
|
||||
Map.entry("crm_deal_contact", List.of("contactId", "contactRoleId", "isPrimary")),
|
||||
Map.entry("crm_deal_product", List.of("productId", "quantity", "unitPrice", "totalAmount")),
|
||||
Map.entry("crm_deal_stage_history", List.of("fromStageId", "toStageId", "transitionDate", "userId")));
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CrmPresentation()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit labels avoid exposing technical table and user-id names.
|
||||
*******************************************************************************/
|
||||
public static void configureTable(QTableMetaData table)
|
||||
{
|
||||
table.setLabel(LABELS.get(table.getName()));
|
||||
Map<String, String> users = Map.of("ownerUserId", "Owner", "assignedToUserId", "Assigned To",
|
||||
"assignToUserId", "Assign To", "enrolledByUserId", "Enrolled By", "convertedByUserId", "Converted By", "userId", "User");
|
||||
table.getFields().values().forEach(field ->
|
||||
{
|
||||
if(users.containsKey(field.getName()))
|
||||
{
|
||||
field.setLabel(users.get(field.getName()));
|
||||
}
|
||||
});
|
||||
Map<String, List<String>> money = Map.of("crm_deal", List.of("amount", "amountInBaseCurrency", "weightedAmount"),
|
||||
"crm_deal_product", List.of("unitPrice", "totalAmount"), "crm_product", List.of("unitPrice"), "crm_sales_goal", List.of("targetAmount"));
|
||||
for(String field : money.getOrDefault(table.getName(), List.of()))
|
||||
{
|
||||
table.getField(field).setDisplayFormat(DisplayFormat.CURRENCY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 supports a per-widget field list, preserving each full child table.
|
||||
*******************************************************************************/
|
||||
public static void configureChildList(QWidgetMetaData widget, String childTable)
|
||||
{
|
||||
List<String> fields = CHILD_FIELDS.get(childTable);
|
||||
if(fields != null)
|
||||
{
|
||||
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,13 +123,14 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
widget.setName(snakeCase(widget.getName()));
|
||||
QCodeReference permissionChecker = widget.getPermissionRules() == null ? null : widget.getPermissionRules().getCustomPermissionChecker();
|
||||
widget.setPermissionRules(CrmPermissions.hasAccessRules(CrmPermissions.BASE).withCustomPermissionChecker(permissionChecker));
|
||||
widget.setGridColumns(12);
|
||||
widget.setGridColumns(com.kingsrook.qbits.crm.widgets.CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
|
||||
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
|
||||
{
|
||||
String normalizedJoinName = snakeCase(joinName);
|
||||
widget.withDefaultValue("joinName", normalizedJoinName);
|
||||
QJoinMetaData join = metaDataProducerMultiOutput.getEach(QJoinMetaData.class).stream()
|
||||
.filter(candidate -> candidate.getName().equals(normalizedJoinName)).findFirst().orElseThrow();
|
||||
CrmPresentation.configureChildList(widget, join.getRightTable());
|
||||
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()));
|
||||
@@ -141,8 +142,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
@@ -165,6 +164,11 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
|
||||
for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class))
|
||||
{
|
||||
String tableName = table.getName();
|
||||
CrmPresentation.configureTable(table);
|
||||
if(tableName.equals(Contact.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class));
|
||||
}
|
||||
if(tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME))
|
||||
{
|
||||
addOrComposeCustomizer(table, TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(CrmRelationshipValidator.class));
|
||||
|
||||
@@ -27,7 +27,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.CrmQBitConfig;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmAssignableEntity;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType;
|
||||
@@ -295,7 +294,7 @@ public class CrmIntakeApi
|
||||
List<QRecord> stages = store.by("crm_lifecycle_stage", "name", "Lead");
|
||||
contact = store.insert("crm_contact", new QRecord().withValue("email", normalize(request.email()))
|
||||
.withValue("firstName", name(request.firstName())).withValue("lastName", name(request.lastName()))
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", CrmSessionUtils.getCurrentUserId())
|
||||
.withValue("phone", request.phone()).withValue("ownerUserId", null)
|
||||
.withValue("leadSourceId", leadSource.getValueInteger("id")).withValue("lifecycleStageId", stages.isEmpty() ? null : stages.get(0).getValueInteger("id")));
|
||||
}
|
||||
else
|
||||
@@ -396,6 +395,6 @@ public class CrmIntakeApi
|
||||
*******************************************************************************/
|
||||
private String name(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? "Not provided" : value);
|
||||
return (value == null || value.isBlank() ? null : value.strip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.core.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.Contact;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 recordLabelFormat is printf-only; preserve a computed query/get label.
|
||||
*******************************************************************************/
|
||||
public class ContactLabelCustomizer extends AbstractPostQueryCustomizer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep missing names absent in storage while giving records useful labels.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> apply(List<QRecord> records)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord visible = new QRecord().withValue("id", record.getValue("id"));
|
||||
for(String name : List.of("firstName", "lastName", "email"))
|
||||
{
|
||||
var field = QContext.getQInstance().getTable(Contact.TABLE_NAME).getField(name);
|
||||
if(FieldSecurityLockHelper.isFieldAccessible(field)
|
||||
&& !(input != null && input.getShouldOmitHiddenFields() && field.getIsHidden())
|
||||
&& !(input != null && input.getShouldMaskPasswords() && field.getType().needsMasked()))
|
||||
{
|
||||
visible.setValue(name, record.getValue(name));
|
||||
}
|
||||
}
|
||||
record.setRecordLabel(label(visible));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer the supplied name, then email, then the record identifier.
|
||||
*******************************************************************************/
|
||||
public static String label(QRecord record)
|
||||
{
|
||||
String name = (Objects.toString(record.getValue("firstName"), "").strip() + " "
|
||||
+ Objects.toString(record.getValue("lastName"), "").strip()).strip();
|
||||
return (name.isBlank() ? Objects.toString(record.getValue("email"), Objects.toString(record.getValue("id"), "Contact")) : name);
|
||||
}
|
||||
}
|
||||
@@ -101,26 +101,23 @@ public class Contact extends QRecordEntity
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
QFieldSection t1Section = SectionFactory.defaultT1("id", "firstName", "lastName", "email", "phone");
|
||||
QFieldSection t1Section = SectionFactory.defaultT1("firstName", "lastName", "email", "phone");
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("person"))
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("firstName", "lastName")
|
||||
.withRecordLabelFormat(null)
|
||||
.withRecordLabelFields(List.of())
|
||||
.withSection(t1Section)
|
||||
.withSection(new QFieldSection("details", new QIcon().withName("info"), Tier.T2,
|
||||
List.of("companyId", "jobTitle", "department", "lifecycleStageId", "leadSourceId",
|
||||
"ownerUserId", "leadScore", "timezone", "preferredLanguage")))
|
||||
.withSection(new QFieldSection("online", new QIcon().withName("language"), Tier.T2,
|
||||
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl")))
|
||||
.withSection(new QFieldSection("address", new QIcon().withName("location_on"), Tier.T2,
|
||||
List.of("addressLine1", "addressLine2", "city", "state", "postalCode", "country")))
|
||||
.withSection(new QFieldSection("communication", new QIcon().withName("settings"), Tier.T2,
|
||||
List.of("doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
"ownerUserId", "leadScore", "timezone", "preferredLanguage", "description")))
|
||||
.withSection(new QFieldSection("contactInformation", "Contact Information", new QIcon("contact_mail"), Tier.T2,
|
||||
List.of("secondaryEmail", "mobilePhone", "website", "linkedinUrl", "addressLine1", "addressLine2", "city", "state", "postalCode", "country",
|
||||
"doNotEmail", "doNotCall", "unsubscribeDate")))
|
||||
.withSection(new QFieldSection("attribution", new QIcon().withName("campaign"), Tier.T2,
|
||||
List.of("firstTouchUtmSource", "firstTouchUtmMedium", "firstTouchUtmCampaign", "firstTouchUtmContent", "firstTouchUtmTerm", "firstTouchLandingPage", "firstTouchReferrer", "firstTouchDate", "lastTouchUtmSource", "lastTouchUtmMedium", "lastTouchUtmCampaign", "lastTouchUtmContent", "lastTouchUtmTerm", "lastTouchLandingPage", "lastTouchReferrer", "lastTouchDate")).withLabel("Attribution"))
|
||||
.withSection(SectionFactory.defaultT2("description"))
|
||||
.withSection(SectionFactory.defaultT3("lastActivityDate", "lastContactedDate", "createDate", "modifyDate"));
|
||||
.withSection(new QFieldSection("system", "System", new QIcon("settings"), Tier.T3,
|
||||
List.of("id", "lastActivityDate", "lastContactedDate", "createDate", "modifyDate")));
|
||||
|
||||
|
||||
|
||||
@@ -181,10 +178,10 @@ public class Contact extends QRecordEntity
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String firstName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String lastName;
|
||||
|
||||
@QField(maxLength = 320, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
|
||||
+8
@@ -31,6 +31,7 @@ package com.kingsrook.qbits.crm.deals.customizers;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.crm.CrmSessionUtils;
|
||||
import com.kingsrook.qbits.crm.deals.model.Deal;
|
||||
@@ -130,6 +131,13 @@ public class DealInitializationCustomizer extends AbstractPostInsertCustomizer
|
||||
}
|
||||
}
|
||||
|
||||
if(stage != null && (Boolean.TRUE.equals(stage.getIsClosedWon()) || Boolean.TRUE.equals(stage.getIsClosedLost()))
|
||||
&& record.getValue("actualCloseDate") == null)
|
||||
{
|
||||
updateRecord.setValue("actualCloseDate", now.atZone(ZoneOffset.UTC).toLocalDate());
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// calculate weightedAmount //
|
||||
///////////////////////////////////////////
|
||||
|
||||
@@ -22,19 +22,26 @@
|
||||
package com.kingsrook.qbits.crm.widgets;
|
||||
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -70,9 +77,41 @@ public class CrmContactTimelineRenderer extends AbstractWidgetRenderer
|
||||
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));
|
||||
rows.add(CrmHomeRenderer.row(AiLinkBuilder.recordHref(table, record.getValue("id")), formatDate(record.getValueInstant("createDate")), kind, detail));
|
||||
}
|
||||
}
|
||||
return (new RenderWidgetOutput(CrmHomeRenderer.table(rows, "Recorded", "Kind", "Details")));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer the session timezone (including DST), then offset, otherwise UTC.
|
||||
*******************************************************************************/
|
||||
static String formatDate(Instant instant)
|
||||
{
|
||||
if(instant == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
ZoneId zone = ZoneOffset.UTC;
|
||||
QSession session = QContext.getQSession();
|
||||
String timezone = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE);
|
||||
String offset = session == null ? null : session.getValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES);
|
||||
try
|
||||
{
|
||||
if(timezone != null && !timezone.isBlank())
|
||||
{
|
||||
zone = ZoneId.of(timezone);
|
||||
}
|
||||
else if(offset != null && !offset.isBlank())
|
||||
{
|
||||
zone = ZoneOffset.ofTotalSeconds(Math.multiplyExact(60, Integer.parseInt(offset)));
|
||||
}
|
||||
}
|
||||
catch(DateTimeException | IllegalArgumentException | ArithmeticException e)
|
||||
{
|
||||
zone = ZoneOffset.UTC;
|
||||
}
|
||||
return (DateTimeFormatter.ofPattern("MMM d, uuuu h:mm a", Locale.US).withZone(zone).format(instant));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ package com.kingsrook.qbits.crm.widgets;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -33,6 +35,7 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.crm.core.model.enums.CrmFormConversionStatus;
|
||||
@@ -118,11 +121,20 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve decimals and large counts in the standard statistics renderer.
|
||||
** Format statistics with the configured base currency in the value.
|
||||
*******************************************************************************/
|
||||
private StatisticsData statistic(BigDecimal value, boolean currency)
|
||||
private StatisticsData statistic(BigDecimal value, boolean currency) throws QException
|
||||
{
|
||||
return (new StatisticsData().withCount(value).withCountContext(currency ? "base currency" : null));
|
||||
String prefix = "";
|
||||
if(currency)
|
||||
{
|
||||
List<QRecord> base = query("crm_currency", equalsFilter("isBaseCurrency", true), "id", true, 1);
|
||||
if(!base.isEmpty() && base.getFirst().getValueString("currencyCode") != null)
|
||||
{
|
||||
prefix = base.getFirst().getValueString("currencyCode") + " ";
|
||||
}
|
||||
}
|
||||
return (new StatisticsData().withCount(prefix + formatAmount(value)));
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +209,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stage and deal count share one column, keeping both monetary columns.
|
||||
** Separate the stage label, integer count and weighted base-currency amount.
|
||||
*******************************************************************************/
|
||||
private TableData pipeline(RenderWidgetInput input) throws QException
|
||||
{
|
||||
@@ -220,10 +232,10 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
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]));
|
||||
rows.add(row(href, stage.getValueString("name"), values[0].toBigIntegerExact(), formatAmount(values[2])));
|
||||
}
|
||||
}
|
||||
TableData data = table(rows, "Stage / deals", "Amount (base)", "Weighted amount (base)");
|
||||
TableData data = table(rows, "Stage", "Deals", "Weighted amount");
|
||||
if(pipelines.size() > 1)
|
||||
{
|
||||
data.withDropdownNameList(List.of("pipelineId")).withDropdownLabelList(List.of("Pipeline"))
|
||||
@@ -246,7 +258,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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]));
|
||||
rows.add(row(AiLinkBuilder.filteredTableHref(DEAL, filter), start.toString().substring(0, 7), totals[0].toBigIntegerExact(), formatAmount(totals[2])));
|
||||
}
|
||||
return (table(rows, "Month", "Deals", "Weighted amount (base)"));
|
||||
}
|
||||
@@ -292,7 +304,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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())
|
||||
if(Objects.toString(lead.getValue("firstName"), "").isBlank() || Objects.toString(lead.getValue("lastName"), "").isBlank())
|
||||
{
|
||||
name = Objects.toString(lead.getValue("email"), "Web lead");
|
||||
}
|
||||
@@ -339,7 +351,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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));
|
||||
goal.getValueString("userId") + " (" + attainment + ")", formatAmount(target), formatAmount(actual)));
|
||||
}
|
||||
TableData data = table(rows, "Owner / attainment", "Target (base)", "Won (base)");
|
||||
return (data);
|
||||
@@ -362,7 +374,7 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
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))));
|
||||
number(result.getAggregateValue(COUNT)).toBigIntegerExact(), formatAmount(number(result.getAggregateValue(AMOUNT)))));
|
||||
}
|
||||
return (table(rows, "Owner", "Won deals", "Won amount (base)"));
|
||||
}
|
||||
@@ -457,6 +469,18 @@ public class CrmHomeRenderer extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group amounts without losing fractional precision or padding whole amounts.
|
||||
*******************************************************************************/
|
||||
static String formatAmount(BigDecimal value)
|
||||
{
|
||||
BigDecimal amount = value == null ? BigDecimal.ZERO : value;
|
||||
DecimalFormat format = new DecimalFormat("#,##0", DecimalFormatSymbols.getInstance(Locale.US));
|
||||
format.setMaximumFractionDigits(Math.max(0, amount.scale()));
|
||||
return (format.format(amount));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every cell navigates to its record, or to the records in an aggregate.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -54,7 +54,7 @@ public class CrmHomeWidgets extends MetaDataProducer<MetaDataProducerMultiOutput
|
||||
{
|
||||
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");
|
||||
List<String> statistics = List.of("Open pipeline", "Won this month", "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));
|
||||
|
||||
@@ -688,3 +688,9 @@ CREATE TABLE IF NOT EXISTS crm_job_state (
|
||||
last_success TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_full_pass TIMESTAMP WITHOUT TIME ZONE
|
||||
);
|
||||
|
||||
--changeset qbit-crm:crm_nullable_intake_names_and_owners
|
||||
ALTER TABLE crm_contact ALTER COLUMN first_name DROP NOT NULL;
|
||||
ALTER TABLE crm_contact ALTER COLUMN last_name DROP NOT NULL;
|
||||
ALTER TABLE crm_contact ALTER COLUMN owner_user_id DROP NOT NULL;
|
||||
ALTER TABLE crm_deal ALTER COLUMN owner_user_id DROP NOT NULL;
|
||||
@@ -357,11 +357,11 @@ entries:
|
||||
- key: table:crm_contact;field:firstName
|
||||
summary: "The person's given name."
|
||||
content: |
|
||||
The person's given name. This is personal data and is removed by Erase Personal Data.
|
||||
The person's given name, when supplied. Email-only contacts may leave names blank. This is personal data and is removed by Erase Personal Data.
|
||||
- key: table:crm_contact;field:lastName
|
||||
summary: "The person's family name."
|
||||
content: |
|
||||
The person's family name. This is personal data and is removed by Erase Personal Data.
|
||||
The person's family name, when supplied. This is personal data and is removed by Erase Personal Data.
|
||||
- key: table:crm_contact;field:email
|
||||
summary: "Email address used to identify or contact the person."
|
||||
content: |
|
||||
@@ -593,11 +593,11 @@ entries:
|
||||
- key: table:crm_form_submission;field:firstName
|
||||
summary: "The person's given name."
|
||||
content: |
|
||||
The person's given name. This is personal data and is removed by Erase Personal Data.
|
||||
The person's given name, when supplied. Email-only contacts may leave names blank. This is personal data and is removed by Erase Personal Data.
|
||||
- key: table:crm_form_submission;field:lastName
|
||||
summary: "The person's family name."
|
||||
content: |
|
||||
The person's family name. This is personal data and is removed by Erase Personal Data.
|
||||
The person's family name, when supplied. This is personal data and is removed by Erase Personal Data.
|
||||
- key: table:crm_form_submission;field:company
|
||||
summary: "The company name as submitted on the form; review it before linking to an existing organization."
|
||||
content: |
|
||||
|
||||
@@ -137,7 +137,7 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No retired trends; all widgets have explicit protection and full width.
|
||||
** No retired trends; all widgets have explicit protection and statistics use compact widths.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void widgets()
|
||||
@@ -147,7 +147,7 @@ class CrmAppMetaDataTest extends BaseTest
|
||||
for(QWidgetMetaDataInterface widget : instance.getWidgets().values())
|
||||
{
|
||||
assertThat(widget.getPermissionRules()).isNotNull();
|
||||
assertThat(widget.getGridColumns()).isEqualTo(12);
|
||||
assertThat(widget.getGridColumns()).isEqualTo(CrmHomeWidgets.TODAY.contains(widget.getName()) ? 4 : 12);
|
||||
assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presentation contracts remain stable across all discovered CRM metadata.
|
||||
*******************************************************************************/
|
||||
class CrmPresentationTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every table has an explicit human label and user identifiers read naturally.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void labelsAndMoney()
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
assertThat(table.getLabel()).isNotBlank().doesNotContain("_").isNotEqualToIgnoringCase(table.getName());
|
||||
table.getFields().values().stream().filter(field -> field.getName().endsWith("UserId"))
|
||||
.forEach(field -> assertThat(field.getLabel()).doesNotContain("User Id"));
|
||||
}
|
||||
assertThat(instance.getTable("crm_contact").getLabel()).isEqualTo("Contacts");
|
||||
assertThat(instance.getTable("crm_deal").getField("amount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_deal_product").getField("totalAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_product").getField("unitPrice").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
assertThat(instance.getTable("crm_sales_goal").getField("targetAmount").getDisplayFormat()).isEqualTo(DisplayFormat.CURRENCY);
|
||||
for(String table : List.of("crm_contact", "crm_deal"))
|
||||
{
|
||||
assertThat(instance.getTable(table).getField("ownerUserId").getIsRequired()).isFalse();
|
||||
assertThat(instance.getTable(table).getField("ownerUserId").getLabel()).isEqualTo("Owner");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All record-screen child lists have at most four valid, deliberate columns.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sectionsAndChildLists()
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var contact = instance.getTable("crm_contact");
|
||||
assertThat(contact.getSections()).extracting(section -> section.getName())
|
||||
.contains("details", "contactInformation", "attribution", "system").doesNotContain("online", "address", "communication", "data", "dates");
|
||||
assertThat(contact.getSections().stream().filter(section -> section.getName().equals("system")).findFirst().orElseThrow().getTier()).isEqualTo(Tier.T3);
|
||||
for(String parent : List.of("crm_contact", "crm_company", "crm_deal"))
|
||||
{
|
||||
instance.getTable(parent).getSections().stream().filter(section -> section.getWidgetName() != null).forEach(section ->
|
||||
{
|
||||
var widget = instance.getWidget(section.getWidgetName());
|
||||
if(widget.getDefaultValues() != null && widget.getDefaultValues().get("joinName") instanceof String joinName)
|
||||
{
|
||||
var child = instance.getTable(instance.getJoin(joinName).getRightTable());
|
||||
List<?> fields = (List<?>) widget.getDefaultValues().get("onlyIncludeFieldNames");
|
||||
assertThat(fields).isNotEmpty().hasSizeLessThanOrEqualTo(4);
|
||||
fields.forEach(field -> assertThat(child.getFields()).containsKey(field.toString()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A computed label must not re-expose fields omitted by the record view.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hiddenNamesStayOutOfLabels() throws Exception
|
||||
{
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
|
||||
.withValue("firstName", "Private").withValue("lastName", "Name").withValue("email", "visible@example.test"))).getRecords().getFirst();
|
||||
var table = QContext.getQInstance().getTable("crm_contact");
|
||||
table.getField("firstName").setIsHidden(true);
|
||||
table.getField("lastName").setIsHidden(true);
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput("crm_contact").withPrimaryKey(inserted.getValue("id"));
|
||||
input.setShouldOmitHiddenFields(true);
|
||||
QRecord visible = new GetAction().execute(input).getRecord();
|
||||
assertThat(visible.getRecordLabel()).isEqualTo("visible@example.test");
|
||||
assertThat(visible.getValue("firstName")).isNull();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing names remain null while get and query return useful record labels.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contactNameFallbacks() throws Exception
|
||||
{
|
||||
for(String[] values : new String[][] {{"Ada", "Lovelace", "Ada Lovelace"}, {"Ada", null, "Ada"}, {null, null, "ada@example.test"}})
|
||||
{
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput("crm_contact").withRecord(new QRecord()
|
||||
.withValue("firstName", values[0]).withValue("lastName", values[1]).withValue("email", "ada@example.test"))).getRecords().getFirst();
|
||||
assertThat(inserted.getErrors()).isNullOrEmpty();
|
||||
QRecord record = GetAction.execute("crm_contact", inserted.getValue("id"));
|
||||
assertThat(record.getRecordLabel()).isEqualTo(values[2]);
|
||||
assertThat(record.getValue("lastName")).isEqualTo(values[1]);
|
||||
}
|
||||
assertThat(new QueryAction().execute(new QueryInput("crm_contact")).getRecords()).extracting(QRecord::getRecordLabel)
|
||||
.containsExactly("Ada Lovelace", "Ada", "ada@example.test");
|
||||
assertThat(ContactLabelCustomizer.label(new QRecord().withValue("firstName", " ").withValue("lastName", " ").withValue("email", "mail@example.test")))
|
||||
.isEqualTo("mail@example.test");
|
||||
}
|
||||
}
|
||||
@@ -270,20 +270,41 @@ class CrmRdbmsTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both install paths contain identical SQL, with one Liquibase changeset per table.
|
||||
** Fresh DDL and the full upgrade changelog produce identical column definitions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLiquibaseMatchesReferenceDdl() throws Exception
|
||||
{
|
||||
String ddl = Files.readString(Path.of("docs/ddl/postgres.sql"));
|
||||
String changelog = Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql"));
|
||||
assertThat(changelog).startsWith("--liquibase formatted sql");
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(34);
|
||||
assertThat(stripComments(changelog)).isEqualTo(stripComments(ddl));
|
||||
assertThat(changelog.lines().filter(line -> line.startsWith("--changeset qbit-crm:")).count()).isEqualTo(35);
|
||||
var freshColumns = schemaColumns();
|
||||
executeSql("DROP ALL OBJECTS;");
|
||||
executeSql(changelog);
|
||||
assertThat(schemaColumns()).isEqualTo(freshColumns);
|
||||
insert("crm_contact", new QRecord().withValue("email", "email-only@example.test"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture persisted types and nullability for both installation paths.
|
||||
*******************************************************************************/
|
||||
private java.util.Map<String, String> schemaColumns() throws Exception
|
||||
{
|
||||
java.util.Map<String, String> columns = new java.util.TreeMap<>();
|
||||
try(ResultSet rows = connection.getMetaData().getColumns(null, "public", "crm_%", null))
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
columns.put(rows.getString("TABLE_NAME") + "." + rows.getString("COLUMN_NAME"),
|
||||
rows.getString("TYPE_NAME") + ":" + rows.getInt("COLUMN_SIZE") + ":" + rows.getInt("NULLABLE"));
|
||||
}
|
||||
}
|
||||
return (columns);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert through QQQ and fail on record-level validation errors.
|
||||
*******************************************************************************/
|
||||
@@ -359,7 +380,7 @@ class CrmRdbmsTest
|
||||
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>");
|
||||
assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open</a>");
|
||||
input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast"));
|
||||
TableData forecast = (TableData) renderer.execute(input).getWidgetData();
|
||||
assertThat(forecast.getRows()).hasSize(6);
|
||||
|
||||
@@ -386,7 +386,9 @@ class CrmActionsTest extends BaseTest
|
||||
put("crm_contact", new QRecord().withValue("id", 10 + i * 2).withValue("lastName", "Contact").withValue("ownerUserId", "original"));
|
||||
}
|
||||
PageObserver.sizes.clear();
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PageObserver.class));
|
||||
QContext.getQInstance().getTable("crm_contact").getCustomizers().remove(TableCustomizers.POST_QUERY_RECORD.getRole());
|
||||
QContext.getQInstance().getTable("crm_contact").withCustomizer(TableCustomizers.POST_QUERY_RECORD, com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer.of(
|
||||
new QCodeReference(com.kingsrook.qbits.crm.core.customizers.ContactLabelCustomizer.class), new QCodeReference(PageObserver.class)));
|
||||
RunProcessOutput result = new RunProcessAction().execute(new RunProcessInput().withProcessName("crm_calculate_lead_scores"));
|
||||
assertThat(result.getValues().get("totalContacts")).isEqualTo(405);
|
||||
assertThat(result.getValues().get("updatedCount")).isEqualTo(405);
|
||||
|
||||
@@ -99,6 +99,23 @@ public class CrmApisTest extends BaseTest
|
||||
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Email-only intake preserves absent names and never assigns the service user.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emailOnlyIntakeHasNoOwner() throws Exception
|
||||
{
|
||||
QContext.getQSession().setIdReference("site-api-system");
|
||||
CrmFormResult result = intake.submitForm(new CrmFormRequest("contact", FormType.CONTACT, null, " ", "only@example.test",
|
||||
null, null, "Hello", false, "Reply", null, null, null, null, null, null, null, null, null));
|
||||
QRecord contact = CrmStore.asUser().get("crm_contact", result.contactId());
|
||||
assertThat(contact.getValue("firstName")).isNull();
|
||||
assertThat(contact.getValue("lastName")).isNull();
|
||||
assertThat(contact.getValue("ownerUserId")).isNull();
|
||||
assertThat(contact.getRecordLabel()).isEqualTo("only@example.test");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Form preserves first touch and never grants implicit marketing consent.
|
||||
*******************************************************************************/
|
||||
@@ -530,7 +547,7 @@ public class CrmApisTest extends BaseTest
|
||||
assertThat(CrmStore.trusted().by("crm_contact", "email", "subscriber@example.test").get(0).getValueString("ownerUserId")).isEqualTo("sales-0");
|
||||
QContext.getQSession().setIdReference("named-owner");
|
||||
Integer owned = intake.submitForm(form("owned@example.test", false, "owned")).contactId();
|
||||
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("named-owner");
|
||||
assertThat(CrmStore.trusted().get("crm_contact", owned).getValueString("ownerUserId")).isEqualTo("sales-1");
|
||||
CrmStore.trusted().update("crm_contact", new QRecord().withValue("id", first.contactId()).withValue("ownerUserId", "system"));
|
||||
intake.submitForm(form("first@example.test", false, "existing-unowned"));
|
||||
assertThat(CrmStore.trusted().get("crm_contact", first.contactId()).getValueString("ownerUserId")).isEqualTo("system");
|
||||
|
||||
@@ -127,7 +127,7 @@ class ContactTest extends BaseTest
|
||||
///////////////////////////////////
|
||||
// verify T1 identity section //
|
||||
///////////////////////////////////
|
||||
assertThat(table.getSections().get(0).getFieldNames()).contains("id", "firstName", "lastName", "email", "phone");
|
||||
assertThat(table.getSections().get(0).getFieldNames()).contains("firstName", "lastName", "email", "phone");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+24
@@ -119,6 +119,30 @@ class DealInitializationCustomizerTest extends BaseTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lost-stage inserts close immediately while imported dates are preserved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void closedStageDates() throws QException
|
||||
{
|
||||
Integer pipeline = insertPipeline("Closed imports");
|
||||
Integer stage = new InsertAction().execute(new InsertInput(PipelineStage.TABLE_NAME).withRecordEntity(new PipelineStage()
|
||||
.withPipelineId(pipeline).withName("Lost").withSortOrder(1).withProbabilityPct(0).withIsClosedLost(true)))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Deal lost = new Deal(GetAction.execute(Deal.TABLE_NAME, insertDeal("Lost", pipeline, stage, BigDecimal.TEN)));
|
||||
assertEquals(java.time.LocalDate.now(java.time.ZoneOffset.UTC), lost.getActualCloseDate());
|
||||
assertNotNull(lost.getStageEnteredDate());
|
||||
java.time.LocalDate closeDate = java.time.LocalDate.of(2025, 1, 2);
|
||||
java.time.Instant entered = java.time.Instant.parse("2025-01-01T10:00:00Z");
|
||||
Integer imported = new InsertAction().execute(new InsertInput(Deal.TABLE_NAME).withRecordEntity(new Deal()
|
||||
.withName("Imported").withPipelineId(pipeline).withPipelineStageId(stage).withActualCloseDate(closeDate).withStageEnteredDate(entered)))
|
||||
.getRecords().getFirst().getValueInteger("id");
|
||||
Deal saved = new Deal(GetAction.execute(Deal.TABLE_NAME, imported));
|
||||
assertEquals(closeDate, saved.getActualCloseDate());
|
||||
assertEquals(entered, saved.getStageEnteredDate());
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Helper methods //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -64,6 +64,26 @@ class CrmContactTimelineWidgetTest extends BaseTest
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session zones observe DST; missing or invalid zones fall back to UTC.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void readableDates()
|
||||
{
|
||||
Instant time = Instant.parse("2026-10-14T20:19:01.142263Z");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "America/Chicago");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 3:19 PM");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(Instant.parse("2026-01-14T20:19:00Z"))).isEqualTo("Jan 14, 2026 2:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "invalid");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 8:19 PM");
|
||||
QContext.getQSession().setValue("UserTimezone", "");
|
||||
QContext.getQSession().setValue("UserTimezoneOffsetMinutes", "120");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(time)).isEqualTo("Oct 14, 2026 10:19 PM");
|
||||
assertThat(CrmContactTimelineRenderer.formatDate(null)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing and nonexistent parent ids never query all contacts' histories.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -82,6 +82,54 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whole and fractional amounts retain grouping without artificial decimals.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void amountFormatting()
|
||||
{
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("72000.000000"))).isEqualTo("72,000");
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("1234.560000"))).isEqualTo("1,234.56");
|
||||
assertThat(CrmHomeRenderer.formatAmount(new BigDecimal("-1234.005"))).isEqualTo("-1,234.005");
|
||||
assertThat(CrmHomeRenderer.formatAmount(null)).isEqualTo("0");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A direct won-stage insert participates in actual-close reporting immediately.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void directWonInsert() throws Exception
|
||||
{
|
||||
seedStages();
|
||||
seed("crm_currency", new QRecord().withValue("currencyCode", "USD").withValue("isBaseCurrency", true));
|
||||
var inserted = new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(new InsertInput("crm_deal")
|
||||
.withRecord(new QRecord().withValue("name", "Direct win").withValue("pipelineId", 1).withValue("pipelineStageId", 2)
|
||||
.withValue("amount", new BigDecimal("72000")).withValue("expectedCloseDate", LocalDate.now().plusYears(1))));
|
||||
assertThat(inserted.getRecords().getFirst().getErrors()).isNullOrEmpty();
|
||||
var deal = com.kingsrook.qqq.backend.core.actions.tables.GetAction.execute("crm_deal", inserted.getRecords().getFirst().getValue("id"));
|
||||
assertThat(deal.getValue("ownerUserId")).isNull();
|
||||
assertThat(deal.getValueLocalDate("actualCloseDate")).isEqualTo(LocalDate.now(ZoneOffset.UTC));
|
||||
assertThat(deal.getValueInstant("stageEnteredDate")).isNotNull();
|
||||
assertThat(((StatisticsData) render("crm_won_month")).getCount()).isEqualTo("USD 72,000");
|
||||
TableData leaderboard = (TableData) render("crm_sales_leaderboard");
|
||||
assertCell(leaderboard, 0, "first", "Unassigned");
|
||||
assertCell(leaderboard, 0, "second", "1");
|
||||
assertCell(leaderboard, 0, "third", "72,000");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A partial submission name uses the email to retain identifying context.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void partialLeadName() throws Exception
|
||||
{
|
||||
seed("crm_form_submission", new QRecord().withValue("firstName", "Lee").withValue("email", "lee@example.test"));
|
||||
assertCell((TableData) render("crm_recent_web_leads"), 0, "first", "lee@example.test");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty data renders zero statistics, six months and no goals card.
|
||||
*******************************************************************************/
|
||||
@@ -119,16 +167,16 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
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)");
|
||||
assertCell(data, 0, "first", "Open");
|
||||
assertCell(data, 0, "second", "2");
|
||||
assertCell(data, 0, "third", "40.5");
|
||||
assertCell(data, 1, "first", "Won");
|
||||
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)");
|
||||
assertCell(other, 0, "first", "Other");
|
||||
selected.addQueryParam("pipelineId", "999");
|
||||
assertThat(((TableData) new CrmHomeRenderer().render(selected).getWidgetData()).getRows()).hasSize(3);
|
||||
assertLinks(data);
|
||||
@@ -223,7 +271,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
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, "second", "500.5");
|
||||
assertCell(data, 0, "third", "250.25");
|
||||
assertLinks(data);
|
||||
}
|
||||
@@ -247,7 +295,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
assertThat(data.getRows()).hasSize(2);
|
||||
assertCell(data, 0, "first", "Ada");
|
||||
assertCell(data, 0, "second", "2");
|
||||
assertCell(data, 0, "third", "251.00");
|
||||
assertCell(data, 0, "third", "251");
|
||||
assertLinks(data);
|
||||
}
|
||||
|
||||
@@ -331,7 +379,7 @@ class CrmHomeWidgetsTest extends BaseTest
|
||||
*******************************************************************************/
|
||||
private void assertStatistic(String name, String expected) throws QException
|
||||
{
|
||||
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString())).isEqualByComparingTo(expected);
|
||||
assertThat(new BigDecimal(((StatisticsData) render(name)).getCount().toString().replace(",", ""))).isEqualByComparingTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user