index.ts19.2 KBView on GitHub /**
* CRM Module Utilities
*/
import type React from 'react';
import {
Megaphone,
Target,
CheckCircle,
Handshake,
Trophy,
Zap,
XCircle,
Archive,
Pause,
type LucideIcon,
} from 'lucide-react';
import type { CRMContact, CRMRow, CRMColumn, CRMRowDictionary } from '../store/crmSlice';
// Import from ./date-format, NOT ./time. time.ts imports chrono-node and fuse.js at module
// scope for natural-language date parsing, and this barrel is reachable from userTasksSlice,
// which modules/store/index.ts registers eagerly — so one pure date helper was dragging both
// NLP libraries onto the critical path of every route.
// See docs/design/frontend-load-performance.md §3 F5.
import { toISODateString } from '@/modules/crm/utils/date-format';
import { getVisibleFieldDefinitions } from '@/modules/crm/utils/background-fields';
import type { ConversationEvent } from '../types';
// Re-export time utility functions
export {
formatRelativeDate,
getHistoryColor,
getScheduledColor,
type FormatRelativeDateOptions,
} from './time';
// Re-export custom field type utilities from types
export { FIELD_TYPE_OPTIONS, fieldTypeRequiresOptions, getFieldTypeLabel } from '../types';
export const CRM_PROVIDER_LABEL: Record<string, string> = {
salesforce: 'Salesforce',
hubspot: 'HubSpot',
attio: 'Attio',
copper: 'Copper',
};
export function getCrmProviderLabel(provider: string | null | undefined): string {
if (!provider) return 'CRM';
return CRM_PROVIDER_LABEL[provider] ?? provider;
}
export function getNewDealsDiscoveredToast(
count: number,
provider: string | null | undefined,
): { message: string; description: string } {
return {
message: `${count} new deal${count === 1 ? '' : 's'} found in ${getCrmProviderLabel(provider)}`,
description: "Processing in the background — they'll appear shortly.",
};
}
/**
* Normalize enum value: Convert user input to consistent format
* Only lowercases and replaces spaces with underscores
* Preserves all other characters (+, -, $, etc.) for uniqueness
*
* Examples:
* "Closed Won" → "closed_won"
* "Series B" → "series_b"
* "Series B+" → "series_b+"
* "Pre-Seed" → "pre-seed"
* "$100K+" → "$100k+"
*/
export function normalizeEnumValue(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, '_');
}
/**
* Format enum value for display: Convert snake_case to lowercase with spaces
* Example: "closed_won" → "closed won"
*/
export function formatEnumValueForDisplay(value: string | null | undefined): string {
if (value === null) return '-';
if (!value) return '';
return value.replace(/_/g, ' ').toLowerCase();
}
/**
* Get display text for an enum value, preferring label over formatted value
* If enumOptions are provided, looks up the option and uses its label if available
* Otherwise falls back to formatEnumValueForDisplay
*
* @param value - The stored enum value (e.g., "closed_won")
* @param enumOptions - Optional array of enum options to look up label
* @returns Display text to show in UI
*/
export function getEnumDisplayText(
value: string | null | undefined,
enumOptions?: Array<{ value: string | null; label?: string }> | undefined,
): string {
// Handle null explicitly - display as "-"
if (value === null) return '-';
if (!value) return '';
// If enumOptions provided, try to find matching option with label
if (enumOptions) {
const option = enumOptions.find((opt) => opt.value === value);
if (option?.label) {
return option.label;
}
}
// Fall back to formatted value
return formatEnumValueForDisplay(value);
}
/**
* Transform server contact data to UI row format
*/
export function transformContactToRow(contact: CRMContact): CRMRow {
const history: ConversationEvent[] = [];
return {
id: contact.id,
name: contact.person?.name || contact.person?.email || 'Unknown',
email: contact.person?.email || '',
company: contact.person?.currentCompany?.name || '',
role: contact.person?.currentRole || '',
dealStatus: contact.status || '',
lastContact: toISODateString(contact.lastContactedAt),
nextSteps: contact.nextStep ? { action: contact.nextStep } : undefined,
history,
notes: contact.notes || '',
// Include any custom fields
...contact.customFields,
};
}
/**
* Map UI column to server field structure
*/
export function mapColumnToServerField(
columnId: string,
value: any,
contact?: any,
): Record<string, any> {
switch (columnId) {
case 'nextSteps':
return { nextStep: value?.action };
case 'priority':
return { priority: value };
case 'dealStatus':
return {
customFields: {
...contact?.customFields,
dealStatus: value,
},
};
default:
// Other fields go to customFields
return {
customFields: {
...contact?.customFields,
[columnId]: value,
},
};
}
}
/**
* Conversation type color mapping (fallback when AOP has no color set)
*/
export function getTypeColor(type: string | null | undefined) {
if (!type) {
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
}
const normalizedType = type.toLowerCase();
switch (normalizedType) {
case 'deals':
case 'deal':
return 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800';
case 'spam':
case 'inbound spam':
case 'inbound cold pitch':
case 'vendor notification':
return 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800';
case 'recruiting':
return 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 dark:hover:bg-purple-800';
case 'partnership':
case 'non-deal professional relationship':
return 'bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-800';
case 'general':
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
default:
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
}
}
export function getTypeTextColor(type: string | null | undefined): string {
if (!type) return 'text-gray-500 dark:text-gray-400';
const normalizedType = type.toLowerCase();
switch (normalizedType) {
case 'deals':
case 'deal':
return 'text-green-600 dark:text-green-400';
case 'spam':
case 'inbound spam':
case 'inbound cold pitch':
case 'vendor notification':
return 'text-red-600 dark:text-red-400';
case 'recruiting':
return 'text-purple-600 dark:text-purple-400';
case 'partnership':
case 'non-deal professional relationship':
return 'text-blue-600 dark:text-blue-400';
default:
return 'text-gray-500 dark:text-gray-400';
}
}
export function getAopTextColorStyle(hexColor: string | null | undefined): React.CSSProperties | null {
if (!hexColor) return null;
return { color: hexColor };
}
/**
* Create inline styles for a badge from a hex color.
* Returns CSS properties for background, text color, and hover state.
*/
export function getAopColorStyles(hexColor: string | null | undefined): React.CSSProperties | null {
if (!hexColor) return null;
// Parse hex to RGB
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Calculate relative luminance to determine if we need light or dark text
// const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Use the hex color for text and a transparent version for background
return {
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.15)`,
color: hexColor,
// For hover, we'll use a slightly more opaque background (handled via CSS class)
};
}
/**
* Color string to Tailwind class mapping (for priority and custom fields)
*/
export function getColorClass(color: string): string {
const normalizedColor = color.toLowerCase();
switch (normalizedColor) {
case 'red':
return 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800';
case 'orange':
return 'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900 dark:text-orange-200 dark:hover:bg-orange-800';
case 'yellow':
return 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-200 dark:hover:bg-yellow-800';
case 'green':
return 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800';
case 'blue':
return 'bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-800';
case 'cyan':
return 'bg-cyan-100 text-cyan-800 hover:bg-cyan-200 dark:bg-cyan-900 dark:text-cyan-200 dark:hover:bg-cyan-800';
case 'teal':
return 'bg-teal-100 text-teal-800 hover:bg-teal-200 dark:bg-teal-900 dark:text-teal-200 dark:hover:bg-teal-800';
case 'emerald':
return 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200 dark:bg-emerald-900 dark:text-emerald-200 dark:hover:bg-emerald-800';
case 'purple':
return 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 dark:hover:bg-purple-800';
case 'gray':
case 'grey':
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
default:
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
}
}
/**
* Get text color class from a simple color string (for icons)
*/
export function getTextColorClass(color: string): string {
const normalizedColor = color.toLowerCase();
switch (normalizedColor) {
case 'red':
return 'text-red-600 dark:text-red-400';
case 'orange':
return 'text-orange-600 dark:text-orange-400';
case 'yellow':
return 'text-yellow-600 dark:text-yellow-400';
case 'green':
return 'text-green-600 dark:text-green-400';
case 'blue':
return 'text-blue-600 dark:text-blue-400';
case 'cyan':
return 'text-cyan-600 dark:text-cyan-400';
case 'teal':
return 'text-teal-600 dark:text-teal-400';
case 'emerald':
return 'text-emerald-600 dark:text-emerald-400';
case 'purple':
return 'text-purple-600 dark:text-purple-400';
case 'gray':
case 'grey':
return 'text-gray-600 dark:text-gray-400';
default:
return 'text-gray-600 dark:text-gray-400';
}
}
/**
* Get dot color for timeline dots (uses darker shades)
*/
export function getDotColor(color: string): string {
const normalizedColor = color.toLowerCase();
switch (normalizedColor) {
case 'blue':
return 'bg-blue-500 border-blue-200 text-white';
case 'cyan':
return 'bg-cyan-500 border-cyan-200 text-white';
case 'teal':
return 'bg-teal-500 border-teal-200 text-white';
case 'green':
return 'bg-green-500 border-green-200 text-white';
case 'emerald':
return 'bg-emerald-500 border-emerald-200 text-white';
case 'purple':
return 'bg-purple-500 border-purple-200 text-white';
case 'red':
return 'bg-red-500 border-red-200 text-white';
case 'orange':
return 'bg-orange-500 border-orange-200 text-white';
case 'yellow':
return 'bg-yellow-500 border-yellow-200 text-white';
case 'gray':
case 'grey':
return 'bg-gray-500 border-gray-200 text-white';
default:
return 'bg-gray-500 border-gray-200 text-white';
}
}
/**
* Get line color for timeline lines (uses semi-transparent darker shades)
*/
export function getLineColor(color: string): string {
const normalizedColor = color.toLowerCase();
switch (normalizedColor) {
case 'blue':
return 'bg-blue-500/50';
case 'cyan':
return 'bg-cyan-500/50';
case 'teal':
return 'bg-teal-500/50';
case 'green':
return 'bg-green-500/50';
case 'emerald':
return 'bg-emerald-500/50';
case 'purple':
return 'bg-purple-500/50';
case 'red':
return 'bg-red-500/50';
case 'orange':
return 'bg-orange-500/50';
case 'yellow':
return 'bg-yellow-500/50';
case 'gray':
case 'grey':
return 'bg-gray-500/50';
default:
return 'bg-gray-500/50';
}
}
/**
* Next step action color mapping
*/
export function getNextStepColor(action: string) {
switch (action.toLowerCase()) {
case 'follow up':
return 'bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-800';
case 'call':
case 'schedule call':
return 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 dark:hover:bg-purple-800';
case 'send email':
case 'email':
return 'bg-cyan-100 text-cyan-800 hover:bg-cyan-200 dark:bg-cyan-900 dark:text-cyan-200 dark:hover:bg-cyan-800';
case 'meeting':
case 'schedule meeting':
return 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-900 dark:text-indigo-200 dark:hover:bg-indigo-800';
case 'send proposal':
case 'proposal':
return 'bg-violet-100 text-violet-800 hover:bg-violet-200 dark:bg-violet-900 dark:text-violet-200 dark:hover:bg-violet-800';
case 'send contract':
case 'contract':
return 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900 dark:text-amber-200 dark:hover:bg-amber-800';
case 'done':
case 'completed':
return 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800';
case 'waiting':
case 'pending':
return 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-200 dark:hover:bg-yellow-800';
case 'onboarding':
return 'bg-teal-100 text-teal-800 hover:bg-teal-200 dark:bg-teal-900 dark:text-teal-200 dark:hover:bg-teal-800';
default:
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
}
}
/**
* Column ID to color class mapping for filter/sort badges
*/
export function getColumnColor(columnId: string): string {
// Custom fields (wm_*) — violet
if (columnId.startsWith('wm_')) {
return 'bg-violet-100 text-violet-800 hover:bg-violet-200 dark:bg-violet-900 dark:text-violet-200 dark:hover:bg-violet-800';
}
// Built-in columns — all share the same sky color
return 'bg-sky-100 text-sky-800 hover:bg-sky-200 dark:bg-sky-900 dark:text-sky-200 dark:hover:bg-sky-800';
}
export function getColumnTextColor(columnId: string): string {
if (columnId.startsWith('wm_')) return 'text-violet-600 dark:text-violet-400';
return 'text-sky-600 dark:text-sky-400';
}
export function computeTableRows(contacts: CRMRowDictionary, columns: CRMColumn[]): CRMRow[] {
const columnIds = columns.map((col) => col.id);
return Object.values(contacts).map((row) => {
const filtered: CRMRow = { id: row.id };
columnIds.forEach((id) => {
if (id === 'id') return;
if (Object.prototype.hasOwnProperty.call(row, id)) {
filtered[id] = row[id];
}
});
return filtered;
});
}
/**
* Icon mapping for status icons
* Maps icon name strings to Lucide icon components
*/
export const STATUS_ICON_MAP: Record<string, LucideIcon> = {
Megaphone,
Target,
CheckCircle,
Handshake,
Trophy,
Zap,
XCircle,
Archive,
Pause,
};
/**
* Core Cedar fields available for CRM field mapping
*/
export const CORE_CEDAR_FIELDS = [
{ value: 'name', label: 'Name' },
{ value: 'status', label: 'Stage' },
{ value: 'priority', label: 'Priority' },
{ value: 'nextSteps', label: 'Next Steps' },
{ value: 'nextStepDate', label: 'Next Step Date' },
{ value: 'dealValue', label: 'Deal Value' },
{ value: 'statusOverview', label: 'Status Overview' },
{ value: 'lastContactedAt', label: 'Last Contacted At' },
] as const;
/**
* Common template values available for CRM field mapping
*/
export const COMMON_CEDAR_VALUES = [{ value: 'today', label: 'Today' }] as const;
/**
* Extract custom field definitions from AOPs and return as array of field options
* @param aops - Array of AOP objects with customFieldDefinitions (can be null)
* @returns Array of field options with value (fieldId) and label
*/
export function extractCustomFieldsFromAOPs(
aops?: Array<{
customFieldDefinitions?: Record<string, { label?: string; background?: boolean }> | null;
}>,
): Array<{ value: string; label: string }> {
const customFields: Array<{ value: string; label: string }> = [];
if (!aops) return customFields;
const seenFields = new Set<string>();
for (const aop of aops) {
if (aop.customFieldDefinitions && typeof aop.customFieldDefinitions === 'object') {
// Exclude background (sub-event taxonomy) fields from CRM field-mapping options.
for (const [fieldId, fieldDef] of Object.entries(
getVisibleFieldDefinitions(aop.customFieldDefinitions),
)) {
if (!seenFields.has(fieldId) && fieldDef.label) {
customFields.push({ value: fieldId, label: fieldDef.label });
seenFields.add(fieldId);
}
}
}
}
return customFields;
}
/**
* Get available Cedar fields for CRM field mapping
* Combines core fields, custom fields from AOPs, and common values
* @param aops - Optional array of AOP objects to extract custom fields from
* @returns Array of field options with value and label
*/
export function getAvailableCedarFields(
aops?: Array<{ customFieldDefinitions?: Record<string, { label?: string }> | null }>,
): Array<{ value: string; label: string }> {
const customFields = extractCustomFieldsFromAOPs(aops);
return [...CORE_CEDAR_FIELDS, ...customFields, ...COMMON_CEDAR_VALUES];
}
/**
* Format deal value with abbreviations (K, M)
* @param value - Deal value in dollars
* @returns Formatted string with currency symbol and abbreviation
*/
export function formatDealValue(value: number | null | undefined): string {
if (!value) return '—';
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value.toLocaleString()}`;
}
/**
* Get color class for deal value badge based on value tiers
* @param value - Deal value in dollars
* @returns Tailwind CSS color classes
*/
export function getDealSizeColor(value: number | null | undefined): string {
if (!value) return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
if (value >= 100000)
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
if (value >= 50000) return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300';
if (value >= 10000)
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300';
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
}
export function getDealSizeTextColor(value: number | null | undefined): string {
if (!value) return 'text-gray-600 dark:text-gray-400';
if (value >= 100000) return 'text-emerald-600 dark:text-emerald-400';
if (value >= 50000) return 'text-blue-600 dark:text-blue-400';
if (value >= 10000) return 'text-purple-600 dark:text-purple-400';
return 'text-gray-600 dark:text-gray-400';
}