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>
This commit is contained in:
286
src/components/notifications/notification-bell.tsx
Normal file
286
src/components/notifications/notification-bell.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"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);
|
||||
}
|
||||
Reference in New Issue
Block a user