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';
}