erp-core/docs/02-fase-core-business/MGN-008-notifications/especificaciones/ET-NOTIF-frontend.md

47 KiB

ET-NOTIF-FRONTEND: Componentes React

Identificacion

Campo Valor
ID ET-NOTIF-FRONTEND
Modulo MGN-008 Notifications
Version 1.0
Estado En Diseno
Framework React + TypeScript
UI Library shadcn/ui
State Zustand
Real-time Socket.io-client
Autor Requirements-Analyst
Fecha 2025-12-05

Descripcion General

Especificacion tecnica del modulo frontend de Notifications. Incluye centro de notificaciones, integracion WebSocket para tiempo real, gestion de preferencias y templates de email con preview.

Estructura de Archivos

apps/frontend/src/modules/notifications/
├── index.ts
├── routes.tsx
├── pages/
│   ├── NotificationCenterPage.tsx
│   ├── NotificationPreferencesPage.tsx
│   ├── EmailTemplatesPage.tsx
│   └── NotificationHistoryPage.tsx
├── components/
│   ├── center/
│   │   ├── NotificationBell.tsx
│   │   ├── NotificationDropdown.tsx
│   │   ├── NotificationList.tsx
│   │   ├── NotificationItem.tsx
│   │   ├── NotificationGroup.tsx
│   │   ├── NotificationFilters.tsx
│   │   └── NotificationEmpty.tsx
│   ├── preferences/
│   │   ├── ChannelPreferences.tsx
│   │   ├── CategoryPreferences.tsx
│   │   ├── DigestSettings.tsx
│   │   ├── QuietHoursSettings.tsx
│   │   └── UnsubscribeModal.tsx
│   ├── templates/
│   │   ├── EmailTemplateList.tsx
│   │   ├── EmailTemplateForm.tsx
│   │   ├── EmailTemplatePreview.tsx
│   │   ├── TemplateVariablesList.tsx
│   │   └── TemplateTestSender.tsx
│   └── shared/
│       ├── NotificationIcon.tsx
│       ├── NotificationBadge.tsx
│       ├── NotificationToast.tsx
│       ├── NotificationSound.tsx
│       └── PushPermissionBanner.tsx
├── stores/
│   ├── notifications.store.ts
│   ├── notification-preferences.store.ts
│   └── email-templates.store.ts
├── hooks/
│   ├── useNotifications.ts
│   ├── useNotificationSocket.ts
│   ├── useNotificationSound.ts
│   ├── usePushNotifications.ts
│   └── useNotificationToast.ts
├── services/
│   ├── notifications.service.ts
│   ├── notification-socket.service.ts
│   ├── push-notifications.service.ts
│   └── email-templates.service.ts
├── lib/
│   ├── socket-client.ts
│   ├── push-manager.ts
│   └── notification-sounds.ts
└── types/
    ├── notification.types.ts
    ├── notification-preferences.types.ts
    └── email-template.types.ts

Types

Notification Types

// types/notification.types.ts

export type NotificationChannel = 'in_app' | 'email' | 'push';
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
export type NotificationStatus = 'pending' | 'sent' | 'read' | 'archived';

export interface Notification {
  id: string;
  tenantId: string;
  userId: string;

  // Content
  title: string;
  body: string;
  category: string;
  priority: NotificationPriority;
  icon?: string;
  image?: string;

  // Links & Actions
  actionUrl?: string;
  actions?: NotificationAction[];

  // Metadata
  metadata?: Record<string, any>;
  groupKey?: string;
  groupCount?: number;

  // Status
  status: NotificationStatus;
  readAt?: string;
  archivedAt?: string;

  // Timestamps
  createdAt: string;
  expiresAt?: string;
}

export interface NotificationAction {
  key: string;
  label: string;
  url?: string;
  style?: 'primary' | 'secondary' | 'destructive';
}

export interface NotificationGroup {
  key: string;
  title: string;
  notifications: Notification[];
  unreadCount: number;
  latestAt: string;
}

export interface NotificationFilters {
  category?: string;
  priority?: NotificationPriority;
  status?: NotificationStatus;
  search?: string;
  fromDate?: string;
  toDate?: string;
  page?: number;
  limit?: number;
}

export interface NotificationStats {
  total: number;
  unread: number;
  byCategory: Record<string, number>;
  byPriority: Record<NotificationPriority, number>;
}

// WebSocket Events
export interface NotificationSocketEvent {
  type: 'new' | 'read' | 'archived' | 'deleted';
  notification?: Notification;
  notificationId?: string;
  stats?: NotificationStats;
}

Notification Preferences Types

// types/notification-preferences.types.ts

export interface NotificationPreferences {
  id: string;
  userId: string;

  // Global
  enabled: boolean;

  // Channels
  channels: {
    inApp: boolean;
    email: boolean;
    push: boolean;
  };

  // Categories
  categories: Record<string, CategoryPreference>;

  // Digest
  digestEnabled: boolean;
  digestFrequency: 'daily' | 'weekly' | 'never';
  digestTime: string; // HH:mm
  digestDays: number[]; // 0-6

  // Quiet Hours
  quietHoursEnabled: boolean;
  quietHoursStart: string; // HH:mm
  quietHoursEnd: string; // HH:mm
  quietHoursTimezone: string;

  // Push
  pushSubscription?: PushSubscriptionJSON;
  pushSound: boolean;
  pushVibration: boolean;

  createdAt: string;
  updatedAt: string;
}

export interface CategoryPreference {
  enabled: boolean;
  channels: {
    inApp: boolean;
    email: boolean;
    push: boolean;
  };
  priority: NotificationPriority;
}

export interface NotificationCategory {
  key: string;
  name: string;
  description: string;
  defaultEnabled: boolean;
  allowDisable: boolean;
  icon: string;
}

export interface UpdatePreferencesDto {
  enabled?: boolean;
  channels?: Partial<NotificationPreferences['channels']>;
  categories?: Record<string, Partial<CategoryPreference>>;
  digestEnabled?: boolean;
  digestFrequency?: 'daily' | 'weekly' | 'never';
  digestTime?: string;
  digestDays?: number[];
  quietHoursEnabled?: boolean;
  quietHoursStart?: string;
  quietHoursEnd?: string;
  pushSound?: boolean;
  pushVibration?: boolean;
}

Email Template Types

// types/email-template.types.ts

export interface EmailTemplate {
  id: string;
  tenantId: string;
  key: string;
  name: string;
  description?: string;
  category: string;

  // Content
  subject: string;
  bodyHtml: string;
  bodyText?: string;

  // Variables
  variables: TemplateVariable[];

  // Settings
  fromName?: string;
  fromEmail?: string;
  replyTo?: string;

  // Status
  isActive: boolean;
  isDefault: boolean;

  // Stats
  sentCount: number;
  lastSentAt?: string;

  createdAt: string;
  updatedAt: string;
}

export interface TemplateVariable {
  key: string;
  name: string;
  description?: string;
  type: 'string' | 'number' | 'date' | 'boolean' | 'url' | 'html';
  required: boolean;
  defaultValue?: string;
  example?: string;
}

export interface CreateTemplateDto {
  key: string;
  name: string;
  description?: string;
  category: string;
  subject: string;
  bodyHtml: string;
  bodyText?: string;
  variables?: TemplateVariable[];
  fromName?: string;
  fromEmail?: string;
}

export interface UpdateTemplateDto {
  name?: string;
  description?: string;
  subject?: string;
  bodyHtml?: string;
  bodyText?: string;
  variables?: TemplateVariable[];
  fromName?: string;
  fromEmail?: string;
  isActive?: boolean;
}

export interface TemplatePreviewRequest {
  templateId: string;
  variables: Record<string, any>;
}

export interface TemplatePreviewResponse {
  subject: string;
  bodyHtml: string;
  bodyText: string;
}

export interface TemplateTestRequest {
  templateId: string;
  toEmail: string;
  variables: Record<string, any>;
}

Stores (Zustand)

Notifications Store

// stores/notifications.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import {
  Notification,
  NotificationFilters,
  NotificationStats,
  NotificationGroup,
} from '../types/notification.types';
import { notificationsService } from '../services/notifications.service';

interface NotificationsState {
  // Data
  notifications: Notification[];
  groups: NotificationGroup[];
  stats: NotificationStats;
  total: number;
  page: number;
  hasMore: boolean;

  // UI State
  isLoading: boolean;
  isLoadingMore: boolean;
  error: string | null;
  filters: NotificationFilters;
  isDropdownOpen: boolean;

  // Actions
  fetchNotifications: (reset?: boolean) => Promise<void>;
  loadMore: () => Promise<void>;
  markAsRead: (id: string) => Promise<void>;
  markAllAsRead: () => Promise<void>;
  archive: (id: string) => Promise<void>;
  delete: (id: string) => Promise<void>;
  setFilters: (filters: Partial<NotificationFilters>) => void;
  setDropdownOpen: (open: boolean) => void;

  // Real-time
  addNotification: (notification: Notification) => void;
  updateNotification: (notification: Notification) => void;
  removeNotification: (id: string) => void;
  updateStats: (stats: NotificationStats) => void;
}

export const useNotificationsStore = create<NotificationsState>()(
  devtools(
    (set, get) => ({
      notifications: [],
      groups: [],
      stats: { total: 0, unread: 0, byCategory: {}, byPriority: { low: 0, normal: 0, high: 0, urgent: 0 } },
      total: 0,
      page: 1,
      hasMore: true,
      isLoading: false,
      isLoadingMore: false,
      error: null,
      filters: { limit: 20 },
      isDropdownOpen: false,

      fetchNotifications: async (reset = true) => {
        const { filters, notifications } = get();

        if (reset) {
          set({ isLoading: true, page: 1 });
        }

        try {
          const result = await notificationsService.getAll({
            ...filters,
            page: reset ? 1 : get().page,
          });

          const newNotifications = reset
            ? result.data
            : [...notifications, ...result.data];

          // Group notifications
          const groups = groupNotifications(newNotifications);

          set({
            notifications: newNotifications,
            groups,
            total: result.meta.total,
            hasMore: result.meta.page < result.meta.totalPages,
            isLoading: false,
            isLoadingMore: false,
          });

          // Fetch stats
          const stats = await notificationsService.getStats();
          set({ stats });
        } catch (error: any) {
          set({ error: error.message, isLoading: false, isLoadingMore: false });
        }
      },

      loadMore: async () => {
        const { hasMore, isLoadingMore, page } = get();
        if (!hasMore || isLoadingMore) return;

        set({ isLoadingMore: true, page: page + 1 });
        await get().fetchNotifications(false);
      },

      markAsRead: async (id: string) => {
        try {
          await notificationsService.markAsRead(id);
          set((state) => ({
            notifications: state.notifications.map((n) =>
              n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n
            ),
            stats: {
              ...state.stats,
              unread: Math.max(0, state.stats.unread - 1),
            },
          }));
        } catch (error: any) {
          set({ error: error.message });
        }
      },

      markAllAsRead: async () => {
        try {
          await notificationsService.markAllAsRead();
          set((state) => ({
            notifications: state.notifications.map((n) => ({
              ...n,
              status: 'read',
              readAt: new Date().toISOString(),
            })),
            stats: { ...state.stats, unread: 0 },
          }));
        } catch (error: any) {
          set({ error: error.message });
        }
      },

      archive: async (id: string) => {
        try {
          await notificationsService.archive(id);
          set((state) => ({
            notifications: state.notifications.filter((n) => n.id !== id),
          }));
        } catch (error: any) {
          set({ error: error.message });
        }
      },

      delete: async (id: string) => {
        try {
          await notificationsService.delete(id);
          set((state) => ({
            notifications: state.notifications.filter((n) => n.id !== id),
          }));
        } catch (error: any) {
          set({ error: error.message });
        }
      },

      setFilters: (filters) => {
        set((state) => ({
          filters: { ...state.filters, ...filters },
        }));
        get().fetchNotifications(true);
      },

      setDropdownOpen: (open) => {
        set({ isDropdownOpen: open });
      },

      // Real-time updates
      addNotification: (notification) => {
        set((state) => ({
          notifications: [notification, ...state.notifications],
          stats: {
            ...state.stats,
            total: state.stats.total + 1,
            unread: state.stats.unread + 1,
          },
        }));
      },

      updateNotification: (notification) => {
        set((state) => ({
          notifications: state.notifications.map((n) =>
            n.id === notification.id ? notification : n
          ),
        }));
      },

      removeNotification: (id) => {
        set((state) => ({
          notifications: state.notifications.filter((n) => n.id !== id),
        }));
      },

      updateStats: (stats) => {
        set({ stats });
      },
    }),
    { name: 'notifications-store' }
  )
);

function groupNotifications(notifications: Notification[]): NotificationGroup[] {
  const groupMap = new Map<string, NotificationGroup>();

  notifications.forEach((notification) => {
    const key = notification.groupKey || notification.id;
    const existing = groupMap.get(key);

    if (existing) {
      existing.notifications.push(notification);
      existing.unreadCount += notification.status !== 'read' ? 1 : 0;
      if (notification.createdAt > existing.latestAt) {
        existing.latestAt = notification.createdAt;
      }
    } else {
      groupMap.set(key, {
        key,
        title: notification.title,
        notifications: [notification],
        unreadCount: notification.status !== 'read' ? 1 : 0,
        latestAt: notification.createdAt,
      });
    }
  });

  return Array.from(groupMap.values()).sort(
    (a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()
  );
}

Custom Hooks

useNotificationSocket

// hooks/useNotificationSocket.ts
import { useEffect, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useNotificationsStore } from '../stores/notifications.store';
import { useNotificationToast } from './useNotificationToast';
import { useNotificationSound } from './useNotificationSound';
import { Notification, NotificationSocketEvent } from '../types/notification.types';

let socket: Socket | null = null;

export function useNotificationSocket() {
  const {
    addNotification,
    updateNotification,
    removeNotification,
    updateStats,
  } = useNotificationsStore();

  const { showToast } = useNotificationToast();
  const { playSound } = useNotificationSound();

  const connect = useCallback((token: string) => {
    if (socket?.connected) return;

    socket = io(`${process.env.REACT_APP_WS_URL}/notifications`, {
      auth: { token },
      transports: ['websocket'],
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    socket.on('connect', () => {
      console.log('Notifications socket connected');
    });

    socket.on('disconnect', (reason) => {
      console.log('Notifications socket disconnected:', reason);
    });

    socket.on('notification', (event: NotificationSocketEvent) => {
      switch (event.type) {
        case 'new':
          if (event.notification) {
            addNotification(event.notification);
            showToast(event.notification);
            playSound(event.notification.priority);
          }
          break;
        case 'read':
          if (event.notification) {
            updateNotification(event.notification);
          }
          break;
        case 'archived':
        case 'deleted':
          if (event.notificationId) {
            removeNotification(event.notificationId);
          }
          break;
      }

      if (event.stats) {
        updateStats(event.stats);
      }
    });

    socket.on('error', (error) => {
      console.error('Notifications socket error:', error);
    });
  }, [addNotification, updateNotification, removeNotification, updateStats, showToast, playSound]);

  const disconnect = useCallback(() => {
    if (socket) {
      socket.disconnect();
      socket = null;
    }
  }, []);

  const emit = useCallback((event: string, data: any) => {
    if (socket?.connected) {
      socket.emit(event, data);
    }
  }, []);

  useEffect(() => {
    return () => {
      disconnect();
    };
  }, [disconnect]);

  return { connect, disconnect, emit, isConnected: socket?.connected ?? false };
}

usePushNotifications

// hooks/usePushNotifications.ts
import { useState, useCallback, useEffect } from 'react';
import { pushNotificationsService } from '../services/push-notifications.service';

type PermissionState = 'prompt' | 'granted' | 'denied' | 'unsupported';

interface UsePushNotificationsReturn {
  permission: PermissionState;
  isSubscribed: boolean;
  isLoading: boolean;
  subscribe: () => Promise<void>;
  unsubscribe: () => Promise<void>;
}

export function usePushNotifications(): UsePushNotificationsReturn {
  const [permission, setPermission] = useState<PermissionState>('prompt');
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    checkSupport();
    checkSubscription();
  }, []);

  const checkSupport = () => {
    if (!('Notification' in window) || !('serviceWorker' in navigator)) {
      setPermission('unsupported');
      return;
    }

    setPermission(Notification.permission as PermissionState);
  };

  const checkSubscription = async () => {
    try {
      const registration = await navigator.serviceWorker.ready;
      const subscription = await registration.pushManager.getSubscription();
      setIsSubscribed(!!subscription);
    } catch (error) {
      console.error('Error checking push subscription:', error);
    }
  };

  const subscribe = useCallback(async () => {
    if (permission === 'unsupported') return;

    setIsLoading(true);
    try {
      // Request permission
      const result = await Notification.requestPermission();
      setPermission(result as PermissionState);

      if (result !== 'granted') {
        throw new Error('Permission denied');
      }

      // Get service worker registration
      const registration = await navigator.serviceWorker.ready;

      // Subscribe to push
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          process.env.REACT_APP_VAPID_PUBLIC_KEY!
        ),
      });

      // Send subscription to server
      await pushNotificationsService.subscribe(subscription.toJSON());
      setIsSubscribed(true);
    } catch (error) {
      console.error('Error subscribing to push:', error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, [permission]);

  const unsubscribe = useCallback(async () => {
    setIsLoading(true);
    try {
      const registration = await navigator.serviceWorker.ready;
      const subscription = await registration.pushManager.getSubscription();

      if (subscription) {
        await subscription.unsubscribe();
        await pushNotificationsService.unsubscribe();
      }

      setIsSubscribed(false);
    } catch (error) {
      console.error('Error unsubscribing from push:', error);
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, []);

  return {
    permission,
    isSubscribed,
    isLoading,
    subscribe,
    unsubscribe,
  };
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

useNotificationToast

// hooks/useNotificationToast.ts
import { useCallback } from 'react';
import { toast } from 'sonner';
import { Notification } from '../types/notification.types';
import { useNotificationsStore } from '../stores/notifications.store';

export function useNotificationToast() {
  const { markAsRead } = useNotificationsStore();

  const showToast = useCallback((notification: Notification) => {
    const toastType = notification.priority === 'urgent' ? 'error' :
                      notification.priority === 'high' ? 'warning' : 'info';

    toast[toastType](notification.title, {
      description: notification.body,
      duration: notification.priority === 'urgent' ? 10000 : 5000,
      action: notification.actionUrl
        ? {
            label: 'Ver',
            onClick: () => {
              markAsRead(notification.id);
              window.location.href = notification.actionUrl!;
            },
          }
        : undefined,
      onDismiss: () => {
        // Optionally mark as read on dismiss
      },
    });
  }, [markAsRead]);

  return { showToast };
}

Components

NotificationBell

// components/center/NotificationBell.tsx
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { NotificationDropdown } from './NotificationDropdown';
import { useNotificationsStore } from '../../stores/notifications.store';
import { cn } from '@/lib/utils';

export function NotificationBell() {
  const { stats, isDropdownOpen, setDropdownOpen } = useNotificationsStore();
  const unreadCount = stats.unread;

  return (
    <Popover open={isDropdownOpen} onOpenChange={setDropdownOpen}>
      <PopoverTrigger asChild>
        <Button variant="ghost" size="icon" className="relative">
          <Bell className="h-5 w-5" />
          {unreadCount > 0 && (
            <span
              className={cn(
                'absolute -top-1 -right-1 flex items-center justify-center',
                'min-w-[18px] h-[18px] rounded-full bg-destructive text-destructive-foreground',
                'text-xs font-medium px-1'
              )}
            >
              {unreadCount > 99 ? '99+' : unreadCount}
            </span>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="w-96 p-0"
        align="end"
        sideOffset={8}
      >
        <NotificationDropdown />
      </PopoverContent>
    </Popover>
  );
}

NotificationDropdown

// components/center/NotificationDropdown.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Settings, CheckCheck, Archive } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { NotificationList } from './NotificationList';
import { NotificationEmpty } from './NotificationEmpty';
import { useNotificationsStore } from '../../stores/notifications.store';

export function NotificationDropdown() {
  const navigate = useNavigate();
  const {
    notifications,
    stats,
    isLoading,
    fetchNotifications,
    markAllAsRead,
    setDropdownOpen,
  } = useNotificationsStore();

  useEffect(() => {
    fetchNotifications();
  }, [fetchNotifications]);

  const unreadNotifications = notifications.filter((n) => n.status !== 'read');
  const hasUnread = unreadNotifications.length > 0;

  return (
    <div className="flex flex-col max-h-[500px]">
      {/* Header */}
      <div className="flex items-center justify-between p-4 border-b">
        <div>
          <h3 className="font-semibold">Notificaciones</h3>
          <p className="text-sm text-muted-foreground">
            {stats.unread > 0 ? `${stats.unread} sin leer` : 'Al dia'}
          </p>
        </div>
        <div className="flex gap-1">
          {hasUnread && (
            <Button
              variant="ghost"
              size="sm"
              onClick={() => markAllAsRead()}
              title="Marcar todas como leidas"
            >
              <CheckCheck className="h-4 w-4" />
            </Button>
          )}
          <Button
            variant="ghost"
            size="sm"
            onClick={() => {
              setDropdownOpen(false);
              navigate('/notifications/preferences');
            }}
            title="Configuracion"
          >
            <Settings className="h-4 w-4" />
          </Button>
        </div>
      </div>

      {/* Content */}
      <Tabs defaultValue="all" className="flex-1">
        <TabsList className="grid w-full grid-cols-2 p-1 m-2">
          <TabsTrigger value="all">Todas</TabsTrigger>
          <TabsTrigger value="unread">
            Sin leer {stats.unread > 0 && `(${stats.unread})`}
          </TabsTrigger>
        </TabsList>

        <ScrollArea className="flex-1">
          <TabsContent value="all" className="m-0">
            {notifications.length > 0 ? (
              <NotificationList notifications={notifications} />
            ) : (
              <NotificationEmpty />
            )}
          </TabsContent>

          <TabsContent value="unread" className="m-0">
            {unreadNotifications.length > 0 ? (
              <NotificationList notifications={unreadNotifications} />
            ) : (
              <NotificationEmpty message="No hay notificaciones sin leer" />
            )}
          </TabsContent>
        </ScrollArea>
      </Tabs>

      {/* Footer */}
      <Separator />
      <div className="p-2">
        <Button
          variant="ghost"
          className="w-full justify-center"
          onClick={() => {
            setDropdownOpen(false);
            navigate('/notifications');
          }}
        >
          Ver todas las notificaciones
        </Button>
      </div>
    </div>
  );
}

NotificationItem

// components/center/NotificationItem.tsx
import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';
import { MoreHorizontal, Archive, Trash2, ExternalLink, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NotificationIcon } from '../shared/NotificationIcon';
import { useNotificationsStore } from '../../stores/notifications.store';
import { Notification } from '../../types/notification.types';
import { cn } from '@/lib/utils';

interface NotificationItemProps {
  notification: Notification;
}

export function NotificationItem({ notification }: NotificationItemProps) {
  const { markAsRead, archive, delete: deleteNotification } = useNotificationsStore();
  const [isHovered, setIsHovered] = useState(false);

  const isUnread = notification.status !== 'read';

  const handleClick = () => {
    if (isUnread) {
      markAsRead(notification.id);
    }
    if (notification.actionUrl) {
      window.location.href = notification.actionUrl;
    }
  };

  const priorityColors = {
    low: 'bg-gray-100 text-gray-600',
    normal: 'bg-blue-100 text-blue-600',
    high: 'bg-amber-100 text-amber-600',
    urgent: 'bg-red-100 text-red-600',
  };

  return (
    <div
      className={cn(
        'flex gap-3 p-4 cursor-pointer transition-colors',
        'hover:bg-muted/50 border-b last:border-b-0',
        isUnread && 'bg-primary/5'
      )}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onClick={handleClick}
    >
      {/* Icon */}
      <div className="flex-shrink-0 mt-1">
        <NotificationIcon
          icon={notification.icon}
          category={notification.category}
          priority={notification.priority}
        />
      </div>

      {/* Content */}
      <div className="flex-1 min-w-0">
        <div className="flex items-start justify-between gap-2">
          <div className="flex items-center gap-2">
            <span className={cn('font-medium', !isUnread && 'text-muted-foreground')}>
              {notification.title}
            </span>
            {notification.priority !== 'normal' && (
              <span
                className={cn(
                  'text-xs px-1.5 py-0.5 rounded',
                  priorityColors[notification.priority]
                )}
              >
                {notification.priority === 'urgent' ? 'Urgente' :
                 notification.priority === 'high' ? 'Alta' : 'Baja'}
              </span>
            )}
          </div>

          {/* Actions */}
          {isHovered && (
            <DropdownMenu>
              <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
                <Button variant="ghost" size="icon" className="h-6 w-6">
                  <MoreHorizontal className="h-4 w-4" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                {isUnread && (
                  <DropdownMenuItem onClick={(e) => {
                    e.stopPropagation();
                    markAsRead(notification.id);
                  }}>
                    <Check className="h-4 w-4 mr-2" />
                    Marcar como leida
                  </DropdownMenuItem>
                )}
                <DropdownMenuItem onClick={(e) => {
                  e.stopPropagation();
                  archive(notification.id);
                }}>
                  <Archive className="h-4 w-4 mr-2" />
                  Archivar
                </DropdownMenuItem>
                <DropdownMenuItem
                  onClick={(e) => {
                    e.stopPropagation();
                    deleteNotification(notification.id);
                  }}
                  className="text-destructive"
                >
                  <Trash2 className="h-4 w-4 mr-2" />
                  Eliminar
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          )}
        </div>

        <p className={cn(
          'text-sm mt-1 line-clamp-2',
          isUnread ? 'text-foreground' : 'text-muted-foreground'
        )}>
          {notification.body}
        </p>

        {/* Actions buttons */}
        {notification.actions && notification.actions.length > 0 && (
          <div className="flex gap-2 mt-2">
            {notification.actions.map((action) => (
              <Button
                key={action.key}
                variant={action.style === 'primary' ? 'default' : 'outline'}
                size="sm"
                onClick={(e) => {
                  e.stopPropagation();
                  if (action.url) window.location.href = action.url;
                }}
              >
                {action.label}
              </Button>
            ))}
          </div>
        )}

        {/* Timestamp */}
        <div className="flex items-center gap-2 mt-2">
          <span className="text-xs text-muted-foreground">
            {formatDistanceToNow(new Date(notification.createdAt), {
              addSuffix: true,
              locale: es,
            })}
          </span>
          {notification.groupCount && notification.groupCount > 1 && (
            <span className="text-xs bg-muted px-1.5 py-0.5 rounded">
              +{notification.groupCount - 1} mas
            </span>
          )}
        </div>
      </div>

      {/* Unread indicator */}
      {isUnread && (
        <div className="flex-shrink-0 mt-2">
          <div className="h-2 w-2 rounded-full bg-primary" />
        </div>
      )}
    </div>
  );
}

EmailTemplatePreview

// components/templates/EmailTemplatePreview.tsx
import { useState, useEffect } from 'react';
import { RefreshCw, Smartphone, Monitor, Mail, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { emailTemplatesService } from '../../services/email-templates.service';
import { EmailTemplate, TemplatePreviewResponse } from '../../types/email-template.types';
import { cn } from '@/lib/utils';

interface EmailTemplatePreviewProps {
  template: EmailTemplate;
  variables: Record<string, any>;
  className?: string;
}

type ViewMode = 'desktop' | 'mobile' | 'text';

export function EmailTemplatePreview({
  template,
  variables,
  className,
}: EmailTemplatePreviewProps) {
  const [preview, setPreview] = useState<TemplatePreviewResponse | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [viewMode, setViewMode] = useState<ViewMode>('desktop');

  useEffect(() => {
    loadPreview();
  }, [template.id, variables]);

  const loadPreview = async () => {
    setIsLoading(true);
    setError(null);
    try {
      const result = await emailTemplatesService.preview({
        templateId: template.id,
        variables,
      });
      setPreview(result);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  };

  if (error) {
    return (
      <Alert variant="destructive">
        <AlertCircle className="h-4 w-4" />
        <AlertDescription>{error}</AlertDescription>
      </Alert>
    );
  }

  return (
    <Card className={className}>
      <CardHeader className="flex flex-row items-center justify-between py-3">
        <CardTitle className="text-base">Vista Previa</CardTitle>
        <div className="flex items-center gap-2">
          <Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
            <TabsList className="h-8">
              <TabsTrigger value="desktop" className="h-6 px-2">
                <Monitor className="h-4 w-4" />
              </TabsTrigger>
              <TabsTrigger value="mobile" className="h-6 px-2">
                <Smartphone className="h-4 w-4" />
              </TabsTrigger>
              <TabsTrigger value="text" className="h-6 px-2">
                <Mail className="h-4 w-4" />
              </TabsTrigger>
            </TabsList>
          </Tabs>
          <Button variant="ghost" size="icon" onClick={loadPreview} disabled={isLoading}>
            <RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
          </Button>
        </div>
      </CardHeader>

      <CardContent className="p-0">
        {isLoading ? (
          <div className="p-4 space-y-4">
            <Skeleton className="h-8 w-3/4" />
            <Skeleton className="h-[400px] w-full" />
          </div>
        ) : preview ? (
          <div className="border-t">
            {/* Subject */}
            <div className="px-4 py-2 bg-muted/50 border-b">
              <span className="text-sm text-muted-foreground">Asunto: </span>
              <span className="text-sm font-medium">{preview.subject}</span>
            </div>

            {/* Body */}
            <div
              className={cn(
                'overflow-auto bg-white',
                viewMode === 'desktop' && 'p-4',
                viewMode === 'mobile' && 'max-w-[375px] mx-auto p-2',
                viewMode === 'text' && 'p-4'
              )}
            >
              {viewMode === 'text' ? (
                <pre className="text-sm whitespace-pre-wrap font-mono">
                  {preview.bodyText}
                </pre>
              ) : (
                <iframe
                  srcDoc={preview.bodyHtml}
                  title="Email Preview"
                  className={cn(
                    'w-full border-0',
                    viewMode === 'desktop' ? 'h-[500px]' : 'h-[600px]'
                  )}
                  sandbox="allow-same-origin"
                />
              )}
            </div>
          </div>
        ) : (
          <div className="p-8 text-center text-muted-foreground">
            Selecciona una plantilla para ver la vista previa
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Pages

NotificationCenterPage

// pages/NotificationCenterPage.tsx
import { useEffect } from 'react';
import { CheckCheck, Settings, Archive, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { NotificationList } from '../components/center/NotificationList';
import { NotificationFilters } from '../components/center/NotificationFilters';
import { NotificationEmpty } from '../components/center/NotificationEmpty';
import { useNotificationsStore } from '../stores/notifications.store';
import { useNavigate } from 'react-router-dom';

export function NotificationCenterPage() {
  const navigate = useNavigate();
  const {
    notifications,
    stats,
    isLoading,
    filters,
    fetchNotifications,
    markAllAsRead,
    setFilters,
  } = useNotificationsStore();

  useEffect(() => {
    fetchNotifications();
  }, [fetchNotifications]);

  const unreadNotifications = notifications.filter((n) => n.status !== 'read');
  const archivedNotifications = notifications.filter((n) => n.status === 'archived');

  return (
    <div className="container mx-auto py-6 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Centro de Notificaciones</h1>
          <p className="text-muted-foreground">
            {stats.unread > 0
              ? `Tienes ${stats.unread} notificaciones sin leer`
              : 'Estas al dia con todas tus notificaciones'}
          </p>
        </div>
        <div className="flex gap-2">
          <Button
            variant="outline"
            onClick={() => markAllAsRead()}
            disabled={stats.unread === 0}
          >
            <CheckCheck className="mr-2 h-4 w-4" />
            Marcar todo como leido
          </Button>
          <Button
            variant="outline"
            onClick={() => navigate('/notifications/preferences')}
          >
            <Settings className="mr-2 h-4 w-4" />
            Preferencias
          </Button>
        </div>
      </div>

      {/* Stats */}
      <div className="grid grid-cols-4 gap-4">
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold">{stats.total}</div>
            <p className="text-sm text-muted-foreground">Total</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-primary">{stats.unread}</div>
            <p className="text-sm text-muted-foreground">Sin leer</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-amber-600">
              {stats.byPriority.high + stats.byPriority.urgent}
            </div>
            <p className="text-sm text-muted-foreground">Alta prioridad</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-muted-foreground">
              {archivedNotifications.length}
            </div>
            <p className="text-sm text-muted-foreground">Archivadas</p>
          </CardContent>
        </Card>
      </div>

      {/* Filters */}
      <NotificationFilters filters={filters} onChange={setFilters} />

      {/* Tabs */}
      <Card>
        <Tabs defaultValue="all">
          <CardHeader className="pb-0">
            <TabsList>
              <TabsTrigger value="all">
                Todas ({notifications.length})
              </TabsTrigger>
              <TabsTrigger value="unread">
                Sin leer ({unreadNotifications.length})
              </TabsTrigger>
              <TabsTrigger value="archived">
                Archivadas ({archivedNotifications.length})
              </TabsTrigger>
            </TabsList>
          </CardHeader>

          <CardContent className="p-0">
            <TabsContent value="all" className="m-0">
              {notifications.length > 0 ? (
                <NotificationList notifications={notifications} />
              ) : (
                <NotificationEmpty />
              )}
            </TabsContent>

            <TabsContent value="unread" className="m-0">
              {unreadNotifications.length > 0 ? (
                <NotificationList notifications={unreadNotifications} />
              ) : (
                <NotificationEmpty message="No hay notificaciones sin leer" />
              )}
            </TabsContent>

            <TabsContent value="archived" className="m-0">
              {archivedNotifications.length > 0 ? (
                <NotificationList notifications={archivedNotifications} />
              ) : (
                <NotificationEmpty message="No hay notificaciones archivadas" />
              )}
            </TabsContent>
          </CardContent>
        </Tabs>
      </Card>
    </div>
  );
}

Routes

// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';

const NotificationCenterPage = lazy(() => import('./pages/NotificationCenterPage'));
const NotificationPreferencesPage = lazy(() => import('./pages/NotificationPreferencesPage'));
const EmailTemplatesPage = lazy(() => import('./pages/EmailTemplatesPage'));
const NotificationHistoryPage = lazy(() => import('./pages/NotificationHistoryPage'));

export const notificationsRoutes: RouteObject[] = [
  {
    path: 'notifications',
    children: [
      {
        index: true,
        element: <NotificationCenterPage />,
      },
      {
        path: 'preferences',
        element: <NotificationPreferencesPage />,
      },
      {
        path: 'templates',
        element: <EmailTemplatesPage />,
      },
      {
        path: 'history',
        element: <NotificationHistoryPage />,
      },
    ],
  },
];

Wireframes

Centro de Notificaciones (Dropdown)

+------------------------------------------+
| Notificaciones            [✓✓] [⚙]       |
| 5 sin leer                               |
+------------------------------------------+
| [Todas     ] [Sin leer (5)]              |
+------------------------------------------+
| [🔔] Nuevo pedido recibido          •    |
|     Pedido #1234 de Cliente ABC          |
|     hace 5 minutos                       |
+------------------------------------------+
| [📧] Factura generada                    |
|     FAC-2025-001234 lista para enviar    |
|     hace 1 hora                          |
+------------------------------------------+
| [⚠] [URGENTE] Pago vencido          •    |
|     3 facturas vencen hoy                |
|     hace 2 horas                         |
|     [Ver facturas] [Recordar despues]    |
+------------------------------------------+
| [👤] Nuevo usuario registrado            |
|     Maria Garcia se unio al equipo       |
|     hace 3 horas                         |
+------------------------------------------+
|          Ver todas las notificaciones    |
+------------------------------------------+

Preferencias de Notificacion

+------------------------------------------------------------------+
| Preferencias de Notificacion                                      |
| Controla como y cuando recibes notificaciones                    |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Canales                                                           |
+------------------------------------------------------------------+
| Notificaciones In-App                    | [  ON  O]            |
| Recibe notificaciones dentro de la app   |                       |
|                                                                   |
| Notificaciones por Email                 | [  ON  O]            |
| Recibe alertas en tu correo              |                       |
|                                                                   |
| Notificaciones Push                      | [OFF    O]           |
| Alertas en tiempo real en tu navegador   |                       |
| [Activar notificaciones push]            |                       |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Categorias                                                        |
+------------------------------------------------------------------+
| Categoria        | In-App | Email | Push | Estado               |
+------------------------------------------------------------------+
| Pedidos          | [✓]    | [✓]   | [ ]  | [Activada]          |
| Facturas         | [✓]    | [✓]   | [✓]  | [Activada]          |
| Pagos            | [✓]    | [✓]   | [✓]  | [Activada]          |
| Sistema          | [✓]    | [ ]   | [ ]  | [Activada]          |
| Marketing        | [ ]    | [ ]   | [ ]  | [Desactivada]       |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Resumen (Digest)                                                  |
+------------------------------------------------------------------+
| [✓] Activar resumen de notificaciones                            |
|                                                                   |
| Frecuencia: [Diario v]   Hora: [09:00 v]                        |
|                                                                   |
| Dias: [✓L] [✓M] [✓M] [✓J] [✓V] [ S] [ D]                        |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Horas de Silencio                                                 |
+------------------------------------------------------------------+
| [✓] Activar horas de silencio                                    |
|                                                                   |
| No molestar de: [22:00 v] a [08:00 v]                            |
| Zona horaria: [America/Mexico_City v]                            |
+------------------------------------------------------------------+

Historial de Cambios

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial

Aprobaciones

Rol Nombre Fecha Firma
Frontend Lead - - [ ]
UX Designer - - [ ]