diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql index 1457ac1..5f9d132 100644 --- a/docs/ddl/postgres.sql +++ b/docs/ddl/postgres.sql @@ -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), diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmPresentation.java b/src/main/java/com/kingsrook/qbits/crm/CrmPresentation.java new file mode 100644 index 0000000..89d4278 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/CrmPresentation.java @@ -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 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> 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 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> 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 fields = CHILD_FIELDS.get(childTable); + if(fields != null) + { + widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(fields)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 1eb2e05..72a177c 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -123,13 +123,14 @@ public class CrmQBitProducer implements QBitMetaDataProducer 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 } } - 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 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)); diff --git a/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java index b724421..90f59e5 100644 --- a/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java +++ b/src/main/java/com/kingsrook/qbits/crm/api/CrmIntakeApi.java @@ -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 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()); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/customizers/ContactLabelCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/core/customizers/ContactLabelCustomizer.java new file mode 100644 index 0000000..0a2aa1c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/core/customizers/ContactLabelCustomizer.java @@ -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 apply(List 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java index 064fe39..12840e0 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java @@ -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) diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java index f92b27c..a2a78a8 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java @@ -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 // /////////////////////////////////////////// diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java index 36cbbeb..9118e32 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineRenderer.java @@ -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)); + } } diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java index 9f5a098..2dc529a 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeRenderer.java @@ -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 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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java index 5c8d5b6..f9eaf6f 100644 --- a/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java +++ b/src/main/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgets.java @@ -54,7 +54,7 @@ public class CrmHomeWidgets extends MetaDataProducer statistics = List.of("Open pipeline (base currency)", "Won this month (base currency)", "New contacts (30 days)", "Web leads (7 days)", "My overdue activities"); + List 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)); diff --git a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql index 4fb5250..b8033c3 100644 --- a/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-crm/crm.changelog.sql @@ -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; diff --git a/src/main/resources/help/qbit-crm.yaml b/src/main/resources/help/qbit-crm.yaml index b5b8dad..8e87252 100644 --- a/src/main/resources/help/qbit-crm.yaml +++ b/src/main/resources/help/qbit-crm.yaml @@ -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: | diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java index b35e4a8..26f20f2 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmAppMetaDataTest.java @@ -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()); } } diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmPresentationTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmPresentationTest.java new file mode 100644 index 0000000..dcdf7a8 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmPresentationTest.java @@ -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"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java index 3f23437..7cfe005 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java @@ -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 schemaColumns() throws Exception + { + java.util.Map 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)"); + assertThat(pipeline.getRows().getFirst().get("first").toString()).endsWith(">Open"); input.setWidgetMetaData(qInstance.getWidget("crm_deal_forecast")); TableData forecast = (TableData) renderer.execute(input).getWidgetData(); assertThat(forecast.getRows()).hasSize(6); diff --git a/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java index 813506b..7c21740 100644 --- a/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/actions/CrmActionsTest.java @@ -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); diff --git a/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java b/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java index e0261a1..bdfc1af 100644 --- a/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/api/CrmApisTest.java @@ -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"); diff --git a/src/test/java/com/kingsrook/qbits/crm/core/model/ContactTest.java b/src/test/java/com/kingsrook/qbits/crm/core/model/ContactTest.java index c70400e..3bee180 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/model/ContactTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/model/ContactTest.java @@ -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"); } diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java index a0065ef..dfecf14 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java @@ -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 // ///////////////////////////////////////////////////////////////////////////// diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java index 6f4a862..df326df 100644 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmContactTimelineWidgetTest.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java index eba487b..a4551d5 100644 --- a/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/widgets/CrmHomeWidgetsTest.java @@ -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); }