- 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>
287 lines
9.0 KiB
TypeScript
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);
|
|
}
|