Files
mexus-app/src/components/notifications/notification-bell.tsx
Mexus a08e7057e8 feat: Add major features - Mejoras 5-10
- Mejora 5: Órdenes de Compra integration in obra detail
- Mejora 6: Portal de Cliente with JWT auth for clients
- Mejora 7: Diagrama de Gantt for project visualization
- Mejora 8: Push Notifications with service worker
- Mejora 9: Activity Log system with templates
- Mejora 10: PWA support with offline capabilities

New features include:
- Fotos gallery with upload/delete
- Bitácora de obra with daily logs
- PDF export for reports, gastos, presupuestos
- Control de asistencia for employees
- Client portal with granular permissions
- Gantt chart with task visualization
- Push notification system
- Activity timeline component
- PWA manifest, icons, and install prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 03:09:38 +00:00

287 lines
9.0 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Bell,
Check,
CheckCheck,
ClipboardList,
DollarSign,
Package,
TrendingUp,
AlertTriangle,
MessageSquare,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
interface Notification {
id: string;
tipo: string;
titulo: string;
mensaje: string;
url: string | null;
leida: boolean;
createdAt: string;
}
const TIPO_ICONS: Record<string, React.ReactNode> = {
TAREA_ASIGNADA: <ClipboardList className="h-4 w-4 text-blue-500" />,
TAREA_COMPLETADA: <Check className="h-4 w-4 text-green-500" />,
GASTO_PENDIENTE: <DollarSign className="h-4 w-4 text-yellow-500" />,
GASTO_APROBADO: <DollarSign className="h-4 w-4 text-green-500" />,
ORDEN_APROBADA: <Package className="h-4 w-4 text-purple-500" />,
AVANCE_REGISTRADO: <TrendingUp className="h-4 w-4 text-blue-500" />,
ALERTA_INVENTARIO: <AlertTriangle className="h-4 w-4 text-red-500" />,
GENERAL: <MessageSquare className="h-4 w-4 text-gray-500" />,
RECORDATORIO: <Bell className="h-4 w-4 text-orange-500" />,
};
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetchNotifications();
// Polling cada 30 segundos
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, []);
// Registrar service worker y suscribirse a push
useEffect(() => {
if ("serviceWorker" in navigator && "PushManager" in window) {
registerServiceWorker();
}
}, []);
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registrado:", registration);
// Solicitar permiso para notificaciones
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToPush(registration);
}
} catch (error) {
console.error("Error registrando Service Worker:", error);
}
};
const subscribeToPush = async (registration: ServiceWorkerRegistration) => {
try {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
console.warn("VAPID public key not configured");
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
// Enviar suscripción al servidor
const p256dhKey = subscription.getKey("p256dh");
const authKey = subscription.getKey("auth");
if (p256dhKey && authKey) {
const p256dhArray = new Uint8Array(p256dhKey);
const authArray = new Uint8Array(authKey);
await fetch("/api/notifications/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(p256dhArray),
auth: arrayBufferToBase64(authArray),
},
}),
});
}
console.log("Suscrito a push notifications");
} catch (error) {
console.error("Error suscribiendo a push:", error);
}
};
const fetchNotifications = async () => {
try {
const res = await fetch("/api/notifications?limit=10");
if (res.ok) {
const data = await res.json();
setNotifications(data.notificaciones);
setUnreadCount(data.unreadCount);
}
} catch (error) {
console.error("Error fetching notifications:", error);
} finally {
setLoading(false);
}
};
const markAsRead = async (notificationId: string) => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationIds: [notificationId] }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, leida: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch (error) {
console.error("Error marking as read:", error);
}
};
const markAllAsRead = async () => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markAllRead: true }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, leida: true })));
setUnreadCount(0);
} catch (error) {
console.error("Error marking all as read:", error);
}
};
const handleNotificationClick = (notification: Notification) => {
if (!notification.leida) {
markAsRead(notification.id);
}
if (notification.url) {
window.location.href = notification.url;
}
setIsOpen(false);
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="end">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Notificaciones</span>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-xs"
onClick={(e) => {
e.preventDefault();
markAllAsRead();
}}
>
<CheckCheck className="h-3 w-3 mr-1" />
Marcar todas
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="max-h-[400px] overflow-y-auto">
{loading ? (
<div className="py-4 text-center text-sm text-muted-foreground">
Cargando...
</div>
) : notifications.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
No tienes notificaciones
</div>
) : (
notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className={`flex items-start gap-3 p-3 cursor-pointer ${
!notification.leida ? "bg-blue-50" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">
{TIPO_ICONS[notification.tipo] || TIPO_ICONS.GENERAL}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{notification.titulo}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.mensaje}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
locale: es,
})}
</p>
</div>
{!notification.leida && (
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
)}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Utilidad para convertir VAPID key
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;
}
// Utilidad para convertir ArrayBuffer a Base64
function arrayBufferToBase64(buffer: Uint8Array): string {
let binary = "";
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
}