# Manual de Implementación: Panel de Anexos en Tiempo Real con AMI y Next.js

Esta guía detalla los pasos para agregar un dashboard de monitoreo de anexos en tiempo real a tu proyecto `panel-admin-asterisk`. La comunicación con Asterisk se realizará a través de AMI, y los eventos se enviarán al frontend mediante Server-Sent Events (SSE) para una actualización en tiempo real.

## Paso 1: Instalar Dependencias

Primero, instala la librería `asterisk-manager` para comunicarte con Asterisk.

```bash
pnpm add asterisk-manager
```

## Paso 2: Actualizar Variables de Entorno

Agrega las credenciales de AMI a tu archivo `.env`. Asegúrate de que el usuario AMI tenga permisos de lectura para los eventos que necesitas (`system`, `call`, `reporting`, etc.).

#### Archivo: `.env` (añadir al final)

```env
# Asterisk Manager Interface (AMI)
AMI_HOST=127.0.0.1
AMI_PORT=5038
AMI_USERNAME=tu_usuario_ami
AMI_PASSWORD=tu_contraseña_ami
```

Ahora, actualiza el esquema de variables de entorno para que tu aplicación las reconozca.

#### Archivo: `src/env.ts` (modificado)

```typescript
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

const env = createEnv({
  server: {
    NODE_ENV: z.enum(["development", "test", "production"]),
    DATABASE_URL: z.string(),
    BETTER_AUTH_SECRET: z.string(),
    BETTER_AUTH_URL: z.string(),
    SKIP_BUILD_CHECKS: z.string(),
    MAIL_HOST: z.string(),
    MAIL_USERNAME: z.string(),
    MAIL_PASSWORD: z.string(),
    MAIL_FROM: z.string().email(),
    SUPER_ADMIN_EMAIL: z.string().email(),
    SUPER_ADMIN_PASSWORD: z.string(),
    SUPER_ADMIN_NAME: z.string(),
    ADMIN_EMAIL: z.string().email().optional(),
    ADMIN_PASSWORD: z.string().optional(),
    ADMIN_NAME: z.string().optional(),
    USER_EMAIL: z.string().email().optional(),
    USER_PASSWORD: z.string().optional(),
    USER_NAME: z.string().optional(),

    // Nuevas variables para AMI
    AMI_HOST: z.string().min(1),
    AMI_PORT: z.coerce.number(),
    AMI_USERNAME: z.string().min(1),
    AMI_PASSWORD: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_BETTER_AUTH_URL: z.string(),
  },
  runtimeEnv: {
    SKIP_BUILD_CHECKS: process.env.SKIP_BUILD_CHECKS,
    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
    DATABASE_URL: process.env.DATABASE_URL,
    NODE_ENV: process.env.NODE_ENV,
    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
    NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
    MAIL_HOST: process.env.MAIL_HOST,
    MAIL_USERNAME: process.env.MAIL_USERNAME,
    MAIL_PASSWORD: process.env.MAIL_PASSWORD,
    MAIL_FROM: process.env.MAIL_FROM,
    SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL,
    SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD,
    SUPER_ADMIN_NAME: process.env.SUPER_ADMIN_NAME,
    ADMIN_EMAIL: process.env.ADMIN_EMAIL,
    ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
    ADMIN_NAME: process.env.ADMIN_NAME,
    USER_EMAIL: process.env.USER_EMAIL,
    USER_PASSWORD: process.env.USER_PASSWORD,
    USER_NAME: process.env.USER_NAME,

    // Mapeo de nuevas variables
    AMI_HOST: process.env.AMI_HOST,
    AMI_PORT: process.env.AMI_PORT,
    AMI_USERNAME: process.env.AMI_USERNAME,
    AMI_PASSWORD: process.env.AMI_PASSWORD,
  },
  skipValidation: !!process.env.SKIP_ENV_VALIDATION,
  emptyStringAsUndefined: true,
});

export default env;

```

## Paso 3: Crear un Servicio Singleton para AMI

Para evitar múltiples conexiones a AMI, crearemos un servicio "singleton" que gestione una única conexión y distribuya los eventos a los clientes conectados a través de un `EventEmitter`.

#### Archivo: `src/lib/ami-manager.ts` (nuevo)

```typescript
import "server-only";

import AsteriskManager from "asterisk-manager";
import { EventEmitter } from "events";
import env from "@/env";

// EventEmitter para desacoplar el cliente AMI de las respuestas HTTP (SSE)
export const amiEvents = new EventEmitter();

let ami: any;
let isConnected = false;

function initializeAmi() {
  if (ami) {
    console.log("AMI Manager ya está inicializado.");
    return ami;
  }

  console.log(`Intentando conectar a AMI en ${env.AMI_HOST}:${env.AMI_PORT}`);

  ami = new AsteriskManager(
    env.AMI_PORT,
    env.AMI_HOST,
    env.AMI_USERNAME,
    env.AMI_PASSWORD,
    true
  );

  // Mantiene la conexión activa y reconecta si se cae
  ami.keepConnected();

  ami.on("connect", () => {
    isConnected = true;
    console.log("Conectado a Asterisk Manager Interface.");
  });

  ami.on("disconnect", () => {
    isConnected = false;
    console.log("Desconectado de Asterisk Manager Interface.");
  });

  ami.on("managerevent", (evt: any) => {
    // Emitimos el evento para que otros módulos (como la ruta SSE) lo escuchen
    amiEvents.emit("amiEvent", evt);
  });

  ami.on("error", (err: Error) => {
    console.error("Error en AMI Manager:", err);
  });

  return ami;
}

// Función para obtener la instancia del cliente AMI
export const getAmiClient = () => {
  if (!ami) {
    return initializeAmi();
  }
  return ami;
};

// Función para verificar el estado de la conexión
export const isAmiConnected = () => isConnected;

```

## Paso 4: Crear la Ruta de API para Server-Sent Events (SSE)

Esta ruta se encargará de mantener una conexión abierta con el cliente y enviarle los eventos de AMI en tiempo real.

#### Archivo: `src/app/api/ami/events/route.ts` (nuevo)

```typescript
import { NextResponse } from "next/server";
import { amiEvents, getAmiClient, isAmiConnected } from "@/lib/ami-manager";

export async function GET() {
  // Inicializa el cliente AMI si aún no lo está
  getAmiClient();

  const stream = new ReadableStream({
    start(controller) {
      const onAmiEvent = (evt: any) => {
        try {
          const data = `data: ${JSON.stringify(evt)}\n\n`;
          controller.enqueue(new TextEncoder().encode(data));
        } catch (e) {
          console.error("Error al enviar evento SSE:", e);
        }
      };

      // Escucha los eventos emitidos por el singleton de AMI
      amiEvents.on("amiEvent", onAmiEvent);

      // Heartbeat para mantener la conexión viva
      const intervalId = setInterval(() => {
        try {
          controller.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
        } catch (e) {
          console.error("Error al enviar heartbeat SSE:", e);
        }
      }, 20000);
      
      // Limpia el listener cuando el cliente se desconecta
      controller.signal.addEventListener("abort", () => {
        amiEvents.removeListener("amiEvent", onAmiEvent);
        clearInterval(intervalId);
        console.log("Cliente SSE desconectado.");
      });
    },
  });

  return new NextResponse(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}
```

## Paso 5: Definir Tipos para el Estado de los Anexos

Agreguemos una interfaz para manejar el estado de nuestros anexos de forma tipada.

#### Archivo: `src/types/asterisk.ts` (nuevo)

```typescript
export interface PeerStatus {
  event: 'PeerStatus';
  channeltype: 'PJSIP' | 'SIP';
  peer: string; // e.g., PJSIP/101
  peerstatus: 'Registered' | 'Unregistered' | 'Reachable' | 'Unreachable' | 'Lagged' | 'Unknown';
  address?: string;
  port?: string;
  cause?: string;
  time?: string;
}

export interface ExtensionState {
  peer: string; // "101"
  status: 'Registered' | 'Unregistered' | 'Reachable' | 'Unreachable' | 'Lagged' | 'Unknown' | 'InUse' | 'Ringing' | 'Busy';
  details: string;
  callerId?: string;
  connectedLine?: string;
}

export interface AmiEvent {
  event: string;
  [key: string]: any;
}
```

## Paso 6: Crear el Componente de Dashboard

Este componente será la interfaz visual que mostrará el estado de los anexos en tiempo real.

#### Archivo: `src/app/(dashboard)/anexos/page.tsx` (nuevo)

```typescript
"use client";

import { useEffect, useState } from "react";
import { Phone, PhoneIncoming, PhoneOff, User } from "lucide-react";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import type { AmiEvent, ExtensionState } from "@/types/asterisk";
import { cn } from "@/lib/utils";

// Mapeo de estados de PJSIP a estados de UI
const getStatusAppearance = (status: ExtensionState['status']) => {
  switch (status) {
    case "Registered":
    case "Reachable":
      return {
        icon: User,
        color: "bg-green-100 border-green-300 text-green-800",
        label: "Disponible",
      };
    case "InUse":
      return {
        icon: Phone,
        color: "bg-orange-100 border-orange-300 text-orange-800",
        label: "En Llamada",
      };
    case "Ringing":
      return {
        icon: PhoneIncoming,
        color: "bg-blue-100 border-blue-300 text-blue-800 animate-pulse",
        label: "Timbrando",
      };
    case "Busy":
       return {
        icon: Phone,
        color: "bg-red-100 border-red-300 text-red-800",
        label: "Ocupado",
      };
    case "Unregistered":
    case "Unreachable":
      return {
        icon: PhoneOff,
        color: "bg-gray-100 border-gray-300 text-gray-500",
        label: "Desconectado",
      };
    default:
      return {
        icon: PhoneOff,
        color: "bg-yellow-100 border-yellow-300 text-yellow-800",
        label: "Desconocido",
      };
  }
};


export default function AnexosPage() {
  const [extensions, setExtensions] = useState<Record<string, ExtensionState>>({});
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Inicializar con una llamada a una acción AMI que liste los peers
    const fetchInitialState = async () => {
       // Aquí podrías hacer una llamada a una API que ejecute `pjsip show endpoints`
       // Por simplicidad, iniciaremos con un estado vacío y esperaremos los eventos.
       setIsLoading(false);
    };

    fetchInitialState();

    const eventSource = new EventSource("/api/ami/events");

    eventSource.onmessage = (event) => {
      const evt: AmiEvent = JSON.parse(event.data);
      
      setExtensions(prev => {
        const newState = { ...prev };
        
        // El evento PeerStatus es clave para PJSIP
        if (evt.event === "PeerStatus" && evt.channeltype === "PJSIP") {
            const peerId = evt.peer.replace("PJSIP/", "");
            if (!newState[peerId]) {
                newState[peerId] = { peer: peerId, status: 'Unknown', details: '' };
            }
            // No sobrescribir si ya está en llamada
            if (newState[peerId].status !== 'InUse' && newState[peerId].status !== 'Ringing') {
                newState[peerId].status = evt.peerstatus;
            }
        }

        // El evento DialState se puede usar para saber si un anexo está ocupado
        if (evt.event === 'DialState' || evt.event === 'DialEnd') {
             const peerId = evt.channel.match(/PJSIP\/(\d+)/)?.[1];
             if (peerId && newState[peerId]) {
                 newState[peerId].status = evt.dialstatus === 'ANSWER' ? 'InUse' : 'Registered';
             }
        }
        
        // Para mostrar quién llama a quién
        if (evt.event === 'DialBegin') {
            const callerId = evt.calleridnum;
            const connectedLine = evt.destcalleridnum;
            const peerId = evt.channel.match(/PJSIP\/(\d+)/)?.[1];
             if (peerId && newState[peerId]) {
                newState[peerId].status = 'Ringing';
                newState[peerId].callerId = callerId;
                newState[peerId].connectedLine = connectedLine;
             }
        }

        if(evt.event === 'Hangup') {
             const peerId = evt.channel.match(/PJSIP\/(\d+)/)?.[1];
             if (peerId && newState[peerId]) {
                 newState[peerId].status = 'Registered';
                 newState[peerId].callerId = undefined;
                 newState[peerId].connectedLine = undefined;
             }
        }

        return newState;
      });
    };

    eventSource.onerror = (err) => {
      console.error("EventSource falló:", err);
      eventSource.close();
    };

    return () => {
      eventSource.close();
    };
  }, []);

  const sortedExtensions = Object.values(extensions).sort((a, b) => a.peer.localeCompare(b.peer));

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-3xl font-bold text-slate-900">Panel de Anexos</h1>
        <p className="text-slate-600">
          Estado en tiempo real de los anexos (PJSIP)
        </p>
      </div>
      
      {isLoading ? (
        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
            {Array.from({length: 12}).map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}
        </div>
      ) : (
        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
          {sortedExtensions.map((ext) => {
            const appearance = getStatusAppearance(ext.status);
            return (
              <Card key={ext.peer} className={cn("transition-all", appearance.color)}>
                <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
                  <CardTitle className="text-sm font-medium">Anexo {ext.peer}</CardTitle>
                  <appearance.icon className="h-4 w-4 text-muted-foreground" />
                </CardHeader>
                <CardContent>
                  <div className="text-lg font-bold">{appearance.label}</div>
                  {ext.callerId && <p className="text-xs text-muted-foreground">De: {ext.callerId}</p>}
                  {ext.connectedLine && <p className="text-xs text-muted-foreground">A: {ext.connectedLine}</p>}
                </CardContent>
              </Card>
            );
          })}
        </div>
      )}
       { !isLoading && sortedExtensions.length === 0 && (
           <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-200 p-12 text-center">
               <h3 className="text-lg font-semibold text-slate-700">Esperando eventos de AMI...</h3>
               <p className="mt-2 text-sm text-slate-500">
                   Asegúrate de que la conexión con Asterisk esté activa y que se estén generando eventos de anexos.
               </p>
           </div>
       )}
    </div>
  );
}
```

## Paso 7: Agregar el Enlace en el Menú Lateral

Finalmente, añade la nueva página al menú de navegación para que sea accesible.

#### Archivo: `src/components/section/Sidebar.tsx` (modificado)

```typescript
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

import {
  ActivitySquare,
  FileClock,
  Home,
  Settings,
  User,
  Users,
  Contact, // Importa un nuevo ícono
} from "lucide-react";

import { cn } from "@/lib/utils";

import { useUserRole } from "@/hooks/useUserRole";

const navigation = [
  {
    name: "Dashboard",
    href: "/dashboard",
    icon: Home,
  },
  // Nuevo enlace para el panel de anexos
  {
    name: "Panel de Anexos",
    href: "/anexos",
    icon: Contact,
    adminOnly: true,
  },
  {
    name: "Reporte CDR",
    href: "/reportes/cdr",
    icon: FileClock,
    adminOnly: true,
  },
  {
    name: "Eventos CEL",
    href: "/reportes/cel",
    icon: ActivitySquare,
    adminOnly: true,
  },
  {
    name: "Mi Perfil",
    href: "/dashboard/perfil",
    icon: User,
  },
  {
    name: "Usuarios",
    href: "/dashboard/usuarios",
    icon: Users,
    adminOnly: true,
  },
  {
    name: "Sistema",
    href: "/dashboard/sistema",
    icon: Settings,
    adminOnly: true,
  },
];

export default function Sidebar() {
  const pathname = usePathname();
  const { isAdmin } = useUserRole();

  return (
    <aside className="fixed inset-y-0 left-0 z-40 w-64 border-r border-slate-200 bg-white">
      <div className="flex h-full flex-col">
        {/* Logo */}
        <div className="flex h-16 items-center border-b border-slate-200 px-6">
          <div className="flex items-center gap-2">
            <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
              <span className="text-sm font-bold text-white">S</span>
            </div>
            <div className="flex flex-col">
              <span className="text-sm font-bold text-slate-900">
                AdminTemplate
              </span>
              <span className="text-xs text-slate-500">Admin Panel</span>
            </div>
          </div>
        </div>

        {/* Navigation */}
        <nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
          {navigation.map((item) => {
            // Ocultar items de admin si el usuario no es admin
            if (item.adminOnly && !isAdmin) {
              return null;
            }

            const isActive = pathname === item.href;
            return (
              <Link
                key={item.name}
                href={item.href}
                className={cn(
                  "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
                  isActive
                    ? "bg-blue-50 text-blue-600"
                    : "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
                )}
              >
                <item.icon className="h-5 w-5" />
                {item.name}
              </Link>
            );
          })}
        </nav>

        {/* Footer */}
        <div className="border-t border-slate-200 p-4">
          <div className="text-xs text-slate-500">
            <p className="font-medium">AdminTemplate v1.0.0</p>
            <p>Panel de Administración</p>
          </div>
        </div>
      </div>
    </aside>
  );
}
```

## Resumen y Próximos Pasos

Con estos cambios, has implementado un sistema completo para visualizar el estado de tus anexos en tiempo real.

1.  **Inicia tu aplicación** con `pnpm dev`.
2.  **Navega** a la nueva sección "Panel de Anexos" en el menú lateral.
3.  **Observa** cómo los estados de los anexos aparecen y se actualizan automáticamente a medida que se conectan, desconectan o entran en llamadas.

El panel actual maneja los eventos `PeerStatus` (para el estado de registro PJSIP) y algunos eventos básicos de llamada (`DialBegin`, `DialState`, `Hangup`). Puedes expandir la lógica en `AnexosPage` para manejar más eventos de AMI y mostrar información más detallada, como lo hace FOP2.