CrmBoundCells.tsx5.1 KBView on GitHub
'use client';

/**
 * Bound cells that are showing CRM data, drawn the way the conversation item draws them.
 *
 * A bound cell holds the RAW value the binding resolver read off the column —
 * `discovery_completed`, `2026-07-06T22:29:56.000Z` — and rendering that verbatim makes a table
 * the one surface in Cedar where a deal does not look like itself. The conversation row, the
 * kanban card, the canvas card and the deal header all render a stage through `FieldBadge` with
 * the label and colour its AOP defines, and a date through `RelativeDateBadge` coloured by how
 * stale or how overdue it is. So does this. The rule is that a field is drawn by WHAT IT IS,
 * not by which screen it happens to be on.
 *
 * Split out of `CellDisplay` rather than branched inside it so the store subscription and the
 * AOP query below run for the cells that need them ONLY, instead of once per visible cell of
 * every type.
 */

import { useMemo } from 'react';

import { FieldBadge } from '@/modules/crm/components/ConversationCellComponents/FieldBadge';
import { mergeEnumOptionsFromAops } from '@/modules/crm/utils/merge-enum-options';
import { getEnumTextColor, type AopMergeableField } from '@/modules/crm/field-enums';
import { getEnumDisplayText } from '@/modules/crm/utils';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';

import { CELL_CONTENT_CLASS } from './constants';

/**
 * Which CRM enum a `binding` reads, or `null` if it reads something else.
 *
 * Matched on the FIELD, not the whole path, because a binding may name its source column
 * (`deal.stage`) instead of the entity (`conversation.stage`) and both mean the same thing.
 * `stage` and `priority` are safe to match bare: in the binding whitelist
 * (services/documents/table/table-bindings.ts) they exist on the conversation entity and
 * nowhere else. `status` is NOT, because a task has one too and a task's `todo`/`done` is a
 * different vocabulary in different colours — so it is matched only through the explicit
 * `conversation.` head.
 */
export function crmEnumFieldForBinding(binding: string | undefined): AopMergeableField | null {
  if (!binding) return null;
  if (binding === 'conversation.status') return 'status';
  const field = binding.slice(binding.lastIndexOf('.') + 1);
  if (field === 'stage') return 'status';
  if (field === 'priority') return 'priority';
  return null;
}

export function CrmEnumCell({ value, field }: { value: string; field: AopMergeableField }) {
  // The AOPs are what carry the stage vocabulary, and this is a cached, deduplicated query
  // with an hour of staleness — on a screen that has already loaded them it is a store read.
  useAOPs();
  const aopsById = useCedarStore((s) => s.aopsById);

  // Merged across every AOP rather than resolved per row: a stage's label and colour are
  // defined once per playbook, a table can hold rows from several, and the row's own AOP is
  // not in the cell. Merging is what `useSidebarAllColumns` does for the same reason.
  const options = useMemo(
    () => mergeEnumOptionsFromAops(Object.values(aopsById), field),
    [aopsById, field],
  );

  const trimmed = value.trim();
  if (!trimmed) return <div className={CELL_CONTENT_CLASS} />;

  return (
    <div className={cn(CELL_CONTENT_CLASS, 'overflow-hidden')}>
      <FieldBadge
        colorClass={getEnumTextColor(trimmed, field, options)}
        className="max-w-full shrink-0 truncate leading-4"
      >
        {getEnumDisplayText(trimmed, options)}
      </FieldBadge>
    </div>
  );
}

/**
 * Bound date fields whose value is in the FUTURE, and so are coloured by how overdue they are
 * rather than by how stale they are.
 *
 * The same split `ConversationItem` makes (`column.id === 'nextStepDate'` takes `scheduled`,
 * every other date takes `history`) — a next step three days out is on track and green, while a
 * last contact three days out would be nonsense.
 */
const SCHEDULED_DATE_FIELDS: ReadonlySet<string> = new Set(['nextStepDate', 'dueDate']);

/** Whether a bound date reads forwards (`scheduled`) or backwards (`history`). */
export function dateColorTypeForBinding(binding: string | undefined): 'history' | 'scheduled' {
  const field = binding ? binding.slice(binding.lastIndexOf('.') + 1) : '';
  return SCHEDULED_DATE_FIELDS.has(field) ? 'scheduled' : 'history';
}

export function BoundDateCell({ value, binding }: { value: string; binding: string | undefined }) {
  const trimmed = value.trim();
  // An unparseable value is shown as itself rather than as "Invalid Date": the cell holds
  // whatever the binding resolved, and a wrong date is a resolver bug worth seeing.
  if (!trimmed || Number.isNaN(new Date(trimmed).getTime())) {
    return <div className={cn(CELL_CONTENT_CLASS, 'overflow-hidden')}>{trimmed}</div>;
  }
  return (
    <div className={cn(CELL_CONTENT_CLASS, 'overflow-hidden')}>
      <RelativeDateBadge
        date={trimmed}
        colorType={dateColorTypeForBinding(binding)}
        className="shrink-0 px-2 py-0.5 text-xs leading-4"
      />
    </div>
  );
}