InboxLinkedDealsSection.tsx6.9 KBView on GitHub
/**
 * "Linked deals" — the CRM half of a custom inbox.
 *
 * A custom inbox is a Gmail query (`label:INBOX …`). This section adds the
 * other axis: restrict it to threads whose linked conversation matches a CRM
 * rule — "deals in the Deals AOP that aren't closed", "Customer Success
 * accounts at risk". The mail query stays a mailbox scope; deal state stays in
 * the CRM filter model. See apps/mail/docs/pipeline-inbox-crm-stage-filter.md.
 *
 * The AOP selection drives everything below it: the filterable columns are
 * that AOP's native fields plus its custom fields, so its real stages appear
 * rather than a hard-coded enum. Changing the AOP drops filters whose columns
 * no longer exist.
 *
 * Sorting is hidden — the mail list is ordered by `latest_message_at`, so a
 * conversation-level sort rule would never be read.
 */

import { Check, ChevronDown, Filter } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { FilterSortPopoverContent } from '@/modules/crm/components/conversation-canvas/FilterSortConfigurationRow';
import type { FilterSortScope } from '@/modules/crm/components/conversation-canvas/filter-sort-scope';
import { buildAopFilterColumns, resolveAops } from '@/modules/crm/hooks/use-aop-filter-columns';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';

/** The `null` entry in `aopIds` — conversations with no AOP assigned. */
const NO_AOP = null;

export type LinkedDealsState = {
  aopIds: (string | null)[];
  config: CanvasFilterSortConfiguration;
};

type InboxLinkedDealsSectionProps = {
  value: LinkedDealsState;
  onChange: (next: LinkedDealsState) => void;
};

export function InboxLinkedDealsSection({ value, onChange }: InboxLinkedDealsSectionProps) {
  const aopsById = useCedarStore((state) => state.aopsById);
  const [pickerOpen, setPickerOpen] = useState(false);

  const aops = useMemo(() => Object.values(aopsById ?? {}), [aopsById]);

  const selectedLabel = useMemo(() => {
    if (value.aopIds.length === 0) return 'Select AOPs…';
    const names = value.aopIds.map((id) =>
      id === NO_AOP ? 'No AOP' : (aopsById?.[id]?.name ?? 'Unknown'),
    );
    return names.length <= 2 ? names.join(', ') : `${names.length} selected`;
  }, [value.aopIds, aopsById]);

  /**
   * Toggling an AOP changes which columns exist, so any filter on a column the
   * new selection doesn't have is dropped — otherwise a stale `wm_<fieldId>`
   * rule would keep narrowing the inbox invisibly, with no row in the UI to
   * remove it from.
   */
  const toggleAop = useCallback(
    (aopId: string | null) => {
      const nextAopIds = value.aopIds.includes(aopId)
        ? value.aopIds.filter((id) => id !== aopId)
        : [...value.aopIds, aopId];

      const survivingIds = new Set(
        buildAopFilterColumns(resolveAops(nextAopIds, aopsById)).map((col) => col.id),
      );
      const nextConfig: CanvasFilterSortConfiguration = {};
      for (const [columnId, entry] of Object.entries(value.config)) {
        if (survivingIds.has(columnId)) nextConfig[columnId] = entry;
      }

      onChange({ aopIds: nextAopIds, config: nextConfig });
    },
    [value, aopsById, onChange],
  );

  const setConfig = useCallback(
    (config: CanvasFilterSortConfiguration) => onChange({ ...value, config }),
    [value, onChange],
  );

  const scope = useMemo(
    (): FilterSortScope => ({
      kind: 'inbox',
      draft: { aopIds: value.aopIds, config: value.config, setConfig },
    }),
    [value.aopIds, value.config, setConfig],
  );

  return (
    <div className="space-y-2">
      <Label>Linked deals</Label>
      <p className="text-muted-foreground text-xs">
        Restrict this inbox to mail on deals that match a CRM rule. Pick the AOPs it covers, then
        filter on their fields — the rule re-evaluates on every load, so a deal that moves to
        Closed Won leaves the inbox on its own.
      </p>

      <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
        <PopoverTrigger asChild>
          <Button
            type="button"
            variant="outline"
            aria-label="Linked deals AOPs"
            className="h-8 w-full max-w-md cursor-pointer justify-between text-xs font-normal"
          >
            <span className="truncate">{selectedLabel}</span>
            <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
          </Button>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-64 p-1">
          <div className="max-h-64 overflow-y-auto">
            {aops.map((aop) => (
              <AopOption
                key=[redacted]
                label={aop.name}
                selected={value.aopIds.includes(aop.id)}
                onSelect={() => toggleAop(aop.id)}
              />
            ))}
            <AopOption
              label="No AOP"
              hint="Conversations with no AOP assigned"
              selected={value.aopIds.includes(NO_AOP)}
              onSelect={() => toggleAop(NO_AOP)}
            />
            {aops.length === 0 && (
              <p className="text-muted-foreground px-2 py-1.5 text-xs">No AOPs available.</p>
            )}
          </div>
        </PopoverContent>
      </Popover>

      {value.aopIds.length > 0 ? (
        // Fixed width, not full width. Each filter row is its own popover trigger,
        // and the leaf popovers are a fixed 384px anchored to the row's left edge —
        // let the row stretch to the dialog's width and those popovers open from a
        // far-right anchor and run off the screen. `max-w-sm` keeps every anchor in
        // the left third, and reads better than a filter stretched across the modal.
        <div className="bg-muted/20 w-full max-w-md rounded-md border p-2">
          <FilterSortPopoverContent scope={scope} hideSort />
        </div>
      ) : (
        <p className="text-muted-foreground flex items-center gap-1.5 text-xs">
          <Filter className="h-3.5 w-3.5" />
          Pick at least one AOP to filter on its stages and fields.
        </p>
      )}
    </div>
  );
}

function AopOption({
  label,
  hint,
  selected,
  onSelect,
}: {
  label: string;
  hint?: string;
  selected: boolean;
  onSelect: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onSelect}
      className={cn(
        'hover:bg-muted/60 flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs',
        selected && 'bg-muted/40',
      )}
    >
      <span className="min-w-0 flex-1">
        <span className="block truncate">{label}</span>
        {hint && <span className="text-muted-foreground block truncate text-xs">{hint}</span>}
      </span>
      {selected && <Check className="text-primary h-3.5 w-3.5 shrink-0" />}
    </button>
  );
}