CRMPresetBar.tsx18.2 KBView on GitHub /**
* CRM Preset Bar Component
*
* Displays saved filter/sort presets and allows creating new ones.
* Layout: [+ Preset] [Preset 1] [Preset 2] ...
*/
import { ArrowDownWideNarrow, Ban, Check, Filter, Plus, Search, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useCRMConfiguration } from '../hooks/use-crm-configuration';
import { getEnumColor, isEnumField } from '../field-enums';
import { useCRMPresets } from '../hooks/use-crm-presets';
import type { CRMPreset } from '@zero/server/schemas';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
/**
* Value badge with color from enum options
*/
function ValueBadge({
value,
field,
isExcluded = false,
enumOptions,
}: {
value: string | null;
field: string;
isExcluded?: boolean;
enumOptions?: { value: string | null; color?: string; enumOrder: number; label?: string }[];
}) {
const displayValue = value === null ? '(empty)' : value;
const fieldKey=[redacted] ? field : 'status'; // fallback for custom fields
// Cast enumOptions to expected type (the null value case is handled by getEnumColor)
const colorClass = getEnumColor(
value,
fieldKey,
enumOptions as Parameters<typeof getEnumColor>[2],
);
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm px-2 py-0.5 text-xs font-semibold',
colorClass,
isExcluded && 'opacity-75',
)}
>
{isExcluded && <Ban className="h-3 w-3 shrink-0" />}
{displayValue}
</span>
);
}
/**
* Small button for each preset
*/
function PresetButton({
preset,
onApply,
onDelete,
summaryItems,
isActive,
}: {
preset: CRMPreset;
onApply: () => void;
onDelete: () => void;
summaryItems: ReturnType<ReturnType<typeof useCRMPresets>['getPresetSummaryItems']>;
isActive: boolean;
}) {
const [showDelete, setShowDelete] = useState(false);
const { columnMap } = useCRMConfiguration();
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="relative inline-flex"
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
<Button
variant={isActive ? 'default' : 'outline'}
size="sm"
onClick={onApply}
className={cn(
'h-7 gap-1.5 px-2.5 text-xs font-medium',
isActive && 'ring-2 ring-primary/20',
)}
>
{isActive && <Check className="h-3 w-3" />}
{preset.name}
</Button>
{showDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full transition-colors"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-md">
<div className="space-y-2">
<p className="font-medium">{preset.name}</p>
{summaryItems.length > 0 ? (
<div className="space-y-1.5">
{/* Non-sort items */}
{summaryItems
.filter((item) => item.type !== 'sort')
.map((item, idx) => {
const enumOptions = columnMap.get(item.field)?.enumOptions;
return (
<div key=[redacted] className="flex flex-wrap items-center gap-1">
<span className="text-muted-foreground text-xs">{item.label}:</span>
{item.type === 'values' && (
<>
{item.includedValues.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={item.field}
isExcluded={false}
enumOptions={enumOptions}
/>
))}
{item.excludedValues.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={item.field}
isExcluded={true}
enumOptions={enumOptions}
/>
))}
</>
)}
{item.type === 'text' && (
<span className="text-xs font-medium">"{item.text}"</span>
)}
{item.type === 'range' && (
<span className="text-xs font-medium">
{item.min} - {item.max}
</span>
)}
</div>
);
})}
{/* Sort items - all in one row */}
{summaryItems.filter((item) => item.type === 'sort').length > 0 && (
<div className="space-y-1">
<span className="text-muted-foreground text-xs font-medium">Sorts:</span>
<div className="flex gap-4">
{summaryItems
.filter((item) => item.type === 'sort')
.map((item, idx) => {
const enumOptions = columnMap.get(item.field)?.enumOptions;
return (
<div key=[redacted] className="space-y-1">
<span className="text-muted-foreground text-xs">{item.label}:</span>
{item.order && item.order.length > 0 ? (
<div className="flex flex-col gap-1">
{item.order.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={item.field}
isExcluded={false}
enumOptions={enumOptions}
/>
))}
</div>
) : (
<span className="text-xs font-medium">{item.direction}</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<p className="text-muted-foreground text-xs">No filters or sorts</p>
)}
</div>
</TooltipContent>
</Tooltip>
);
}
/**
* Display current configuration summary for the save popover
*/
function CurrentConfigurationSummary({
configuration,
selectedAopNames,
}: {
configuration: ReturnType<typeof useCRMPresets>['getCurrentConfiguration'] extends () => infer R
? R
: never;
selectedAopNames: (string | null)[];
}) {
const { columnMap } = useCRMConfiguration();
// Build structured filter items - group included and excluded values by field
const filterItems = useMemo(() => {
const result: {
field: string;
label: string;
includedValues: (string | null)[];
excludedValues: (string | null)[];
}[] = [];
// Add type/AOP as the first filter if selected
if (selectedAopNames.length > 0) {
result.push({
field: 'type',
label: 'type',
includedValues: selectedAopNames,
excludedValues: [],
});
}
Object.entries(configuration.columns).forEach(([columnId, config]) => {
if (config.filter) {
const label = columnId.replace('wm_', '');
const hasSelected = config.filter.selected && config.filter.selected.length > 0;
const hasExcluded = config.filter.excluded && config.filter.excluded.length > 0;
if (hasSelected || hasExcluded) {
result.push({
field: columnId,
label,
includedValues: config.filter.selected || [],
excludedValues: config.filter.excluded || [],
});
}
}
});
return result;
}, [configuration.columns, selectedAopNames]);
// Build text/range filters
const otherFilters = useMemo(() => {
const result: { label: string; display: string }[] = [];
Object.entries(configuration.columns).forEach(([columnId, config]) => {
if (config.filter) {
const label = columnId.replace('wm_', '');
if (config.filter.searchText) {
result.push({
label,
display: `"${config.filter.searchText}"`,
});
}
if (config.filter.min || config.filter.max) {
const min = config.filter.min || '0';
const max = config.filter.max || '∞';
result.push({
label,
display: `${min} - ${max}`,
});
}
if (config.filter.dateFrom || config.filter.dateTo) {
const from = config.filter.dateFrom || 'any';
const to = config.filter.dateTo || 'any';
result.push({
label,
display: `${from} to ${to}`,
});
}
}
});
return result;
}, [configuration.columns]);
const sorts = useMemo(() => {
const result: {
columnId: string;
direction: string;
priority: number;
order: (string | null)[] | undefined;
}[] = [];
Object.entries(configuration.columns).forEach(([columnId, config]) => {
if (config.sort?.active) {
result.push({
columnId,
direction: config.sort.direction,
priority: config.sort.priority,
order: config.sort.order,
});
}
});
return result.sort((a, b) => a.priority - b.priority);
}, [configuration.columns]);
const hasContent =
filterItems.length > 0 ||
otherFilters.length > 0 ||
sorts.length > 0 ||
configuration.searchQuery;
if (!hasContent) {
return (
<div className="text-muted-foreground py-2 text-center text-sm">
No active configuration to save
</div>
);
}
return (
<div className="space-y-3">
{/* Search query */}
{configuration.searchQuery && (
<div className="flex items-start gap-2 text-sm">
<Search className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
<span className="text-muted-foreground">search:</span>{' '}
<span className="font-medium">"{configuration.searchQuery}"</span>
</span>
</div>
)}
{/* Filters with badges */}
{(filterItems.length > 0 || otherFilters.length > 0) && (
<div className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Filter className="h-3 w-3" />
Filters
</div>
<div className="space-y-1.5">
{filterItems.map((item, idx) => {
const enumOptions = columnMap.get(item.field)?.enumOptions;
return (
<div key=[redacted] className="flex flex-wrap items-center gap-1">
<span className="text-muted-foreground text-xs min-w-[60px]">{item.label}:</span>
{item.includedValues.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={item.field}
isExcluded={false}
enumOptions={enumOptions}
/>
))}
{item.excludedValues.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={item.field}
isExcluded={true}
enumOptions={enumOptions}
/>
))}
</div>
);
})}
{otherFilters.map((item, idx) => (
<div key=[redacted] className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground text-xs min-w-[60px]">{item.label}:</span>
<span className="text-xs font-medium">{item.display}</span>
</div>
))}
</div>
</div>
)}
{/* Sorts */}
{sorts.length > 0 && (
<div className="space-y-1.5">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<ArrowDownWideNarrow className="h-3 w-3" />
Sorts
</div>
<div className="flex gap-4">
{sorts.map((s) => {
const enumOptions = columnMap.get(s.columnId)?.enumOptions;
return (
<div key=[redacted] className="space-y-1">
<span className="text-muted-foreground text-xs">
{s.columnId.replace('wm_', '')}:
</span>
{s.order && s.order.length > 0 ? (
<div className="flex flex-col gap-1">
{s.order.map((v, i) => (
<ValueBadge
key=[redacted]
value={v}
field={s.columnId}
isExcluded={false}
enumOptions={enumOptions}
/>
))}
</div>
) : (
<span className="text-xs font-medium">{s.direction}</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
/**
* Main CRM Preset Bar component
*/
export function CRMPresetBar() {
const {
presets,
isSaving,
hasActiveConfiguration,
getCurrentConfiguration,
savePreset,
applyPreset,
deletePreset,
getPresetSummaryItems,
isPresetActive,
selectedAopNames,
} = useCRMPresets();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [presetName, setPresetName] = useState('');
const currentConfig = useMemo(() => getCurrentConfiguration(), [getCurrentConfiguration]);
const handleSavePreset = async () => {
if (!presetName.trim()) return;
try {
await savePreset(presetName.trim());
setPresetName('');
setIsPopoverOpen(false);
} catch (error) {
// Error is already logged and toasted in the hook, but we should log it here too for debugging
console.error('Failed to save preset in component:', error);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && presetName.trim()) {
e.preventDefault();
handleSavePreset();
}
if (e.key === 'Escape') {
setIsPopoverOpen(false);
}
};
return (
<div className="flex items-center gap-2 overflow-x-auto py-1">
{/* Add Preset Button with Popover */}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium',
hasActiveConfiguration && 'border-primary/50 text-primary',
)}
>
<Plus className="h-3.5 w-3.5" />
Preset
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div>
<h4 className="font-medium leading-none">Save as Preset</h4>
<p className="text-muted-foreground mt-1 text-xs">
Save current filters and sorts as a reusable preset
</p>
</div>
{/* Current configuration summary */}
<div className="bg-muted/50 rounded-md border p-3">
<CurrentConfigurationSummary
configuration={currentConfig}
selectedAopNames={selectedAopNames}
/>
</div>
{/* Preset name input */}
<div className="space-y-2">
<Label htmlFor="preset-name" className="text-sm">
Preset Name
</Label>
<Input
id="preset-name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., High Priority Deals"
className="h-8"
autoFocus
/>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setIsPopoverOpen(false)}
>
Cancel
</Button>
<Button
size="sm"
className="flex-1"
onClick={handleSavePreset}
disabled={!presetName.trim() || !hasActiveConfiguration || isSaving}
>
{isSaving ? 'Saving...' : 'Save Preset'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/* Preset buttons */}
{presets.map((preset) => (
<PresetButton
key=[redacted]
preset={preset}
onApply={() => applyPreset(preset)}
onDelete={() => deletePreset(preset.id)}
summaryItems={getPresetSummaryItems(preset)}
isActive={isPresetActive(preset)}
/>
))}
{/* Empty state hint */}
{presets.length === 0 && (
<span className="text-muted-foreground shrink-0 text-xs">
Save filter configurations as presets for quick access
</span>
)}
</div>
);
}