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">&quot;{item.text}&quot;</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">&quot;{configuration.searchQuery}&quot;</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>
  );
}