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