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