feat: implement notification dropdown in header with centralized navigation logic

This commit is contained in:
2026-07-21 21:51:13 -03:00
parent 98f0ec3102
commit b26c3e58d1
5 changed files with 580 additions and 102 deletions
@@ -40,3 +40,10 @@
.userProfileOptionsContainer a {
margin-top: 10px;
}
@media (max-width: 640px) {
.userProfileOptionsContainer {
left: 10px;
right: 10px;
}
}
@@ -0,0 +1,79 @@
import { ISystemNotification, NotificationType } from "@core/Models/SystemNotifications.model";
import API from "@services/Api.Service";
type ResolveNotificationRouteParams = {
notification: ISystemNotification;
sessionUser: string;
organizationSubscriptions?: Record<string, unknown>;
};
export const NOTIFICATIONS_ROUTE = "/user/profile/notifications";
export const resolveNotificationRoute = async ({
notification,
sessionUser,
organizationSubscriptions,
}: ResolveNotificationRouteParams): Promise<string> => {
if (!notification.type || !notification.code) {
return NOTIFICATIONS_ROUTE;
}
let companyId = notification.companyId;
switch (notification.type) {
case NotificationType.APPOINTMENT: {
const aptRes = await API.post<any>("appointments/get-event", {
appointmentId: notification.code,
sessionUser,
});
companyId = companyId || aptRes?.companyId;
if (aptRes?.clientId && companyId) {
return `/admin/org/${companyId}/client/${aptRes.clientId}/appointments/${notification.code}`;
}
break;
}
case NotificationType.COMPANY:
return `/admin/org/profile/${notification.code}`;
case NotificationType.EMPLOYEE:
case NotificationType.SCHEDULE:
case NotificationType.SCHEDULE_EXCEPTION:
case NotificationType.SCHEDULE_RESTRICTION:
if (!companyId) {
const empRes = await API.post<any>("employees/get-by-id", {
id: notification.code,
sessionUser,
});
companyId = empRes?.companyId;
}
if (companyId) {
if (
notification.type === NotificationType.EMPLOYEE ||
notification.type === NotificationType.SCHEDULE
) {
return `/admin/org/profile/${companyId}/collaborators/${notification.code}`;
}
if (notification.type === NotificationType.SCHEDULE_EXCEPTION) {
return `/admin/org/profile/${companyId}/collaborators/${notification.code}/exceptions`;
}
return `/admin/org/profile/${companyId}/collaborators/${notification.code}/restrictions`;
}
break;
case NotificationType.BILLING:
companyId = companyId || Object.keys(organizationSubscriptions || {})[0];
if (companyId) {
return `/admin/org/profile/${companyId}/dashboard`;
}
break;
case NotificationType.MESSAGE:
if (notification.conversationId) {
return `/messages?conversation=${notification.conversationId}`;
}
break;
}
return NOTIFICATIONS_ROUTE;
};
@@ -14,7 +14,6 @@ import {
UpdateSystemNotificationParams,
NotificationType,
} from "@core/Models/SystemNotifications.model";
import API from "@services/Api.Service";
import { useRouter } from "next/navigation";
import {
deleteNotificationsByDate,
@@ -23,18 +22,19 @@ import {
setStateNotificationByUser,
updateSystemNotification,
} from "./Notifications.Service";
import { resolveNotificationRoute } from "./Notifications.Navigation";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import MarkReadIcon from "@mui/icons-material/MarkChatReadOutlined";
import MarkUnReadIcon from "@mui/icons-material/MarkUnreadChatAltOutlined";
import ReloadIcon from "@mui/icons-material/Autorenew";
import DoneAllIcon from "@mui/icons-material/DoneAll";
import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive";
import InboxIcon from "@mui/icons-material/Inbox";
import IconButton from "@mui/material/IconButton";
import Pagination from "@mui/material/Pagination";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { useMenuStore } from "@core/Store/Menu.Store";
import useWindowSize from "@hooks/WindowSize";
import dayjs from "dayjs";
import "dayjs/locale/es";
import { motion, AnimatePresence } from "motion/react";
@@ -49,6 +49,7 @@ export default function EditProfile() {
const location = useLocationStore();
const menu = useMenuStore();
const router = useRouter();
const [windowWidth] = useWindowSize();
const [filterType, setFilterType] = useState<string>("ALL");
const [pageSize] = useState<number>(
parseInt(
@@ -129,66 +130,22 @@ export default function EditProfile() {
};
const handleNotificationClick = async (notif: SystemNotificationRow) => {
if (!notif.readed) {
handleMarkRead(notif);
}
if (!notif.type || !notif.code) return;
eventHandler.setEventType(EVENT_TYPES.LOADING);
try {
let companyId = notif.companyId;
switch (notif.type) {
case NotificationType.APPOINTMENT:
// Fetch appointment to get clientId and companyId
const aptRes = await API.post<any>("appointments/get-event", {
appointmentId: notif.code,
sessionUser: SessionInfo.userId,
});
if (aptRes && aptRes.clientId) {
companyId = companyId || aptRes.companyId;
if (companyId) {
router.push(`/admin/org/${companyId}/client/${aptRes.clientId}/appointments/${notif.code}`);
}
}
break;
case NotificationType.COMPANY:
router.push(`/admin/org/profile/${notif.code}`);
break;
case NotificationType.EMPLOYEE:
case NotificationType.SCHEDULE:
case NotificationType.SCHEDULE_EXCEPTION:
case NotificationType.SCHEDULE_RESTRICTION:
if (!companyId) {
const empRes = await API.post<any>("employees/get-by-id", {
id: notif.code,
sessionUser: SessionInfo.userId,
});
companyId = companyId || empRes?.companyId;
}
if (companyId) {
if (notif.type === NotificationType.EMPLOYEE || notif.type === NotificationType.SCHEDULE) {
router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}`);
} else if (notif.type === NotificationType.SCHEDULE_EXCEPTION) {
router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}/exceptions`);
} else {
router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}/restrictions`);
}
}
break;
case NotificationType.BILLING:
companyId = companyId || Object.keys(SessionInfo.organizationSubscriptions || {})[0];
if (companyId) {
router.push(`/admin/org/profile/${companyId}/dashboard`);
}
break;
case NotificationType.MESSAGE:
if (notif.conversationId) {
router.push(`/messages?conversation=${notif.conversationId}`);
}
break;
if (!notif.readed) {
await updateSystemNotification({
id: notif._id,
readed: true,
sessionUser: SessionInfo.userId,
});
}
const route = await resolveNotificationRoute({
notification: notif,
sessionUser: SessionInfo.userId,
organizationSubscriptions: SessionInfo.organizationSubscriptions,
});
router.push(route);
} catch (e: any) {
console.error("Error navigating from notification", e);
alert.showError(e.format ? e.format() : "Error al navegar: " + (e.message || "Desconocido"));
@@ -332,7 +289,7 @@ export default function EditProfile() {
});
location.add({
url: "/user/profile/edit",
url: "/user/profile/notifications",
title: "Notificaciones",
icon: <AccountCircleOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
@@ -395,27 +352,55 @@ export default function EditProfile() {
const unreadCount = allDataList.filter(n => !n.readed).length;
const isInboxZero = allDataList.length > 0 && unreadCount === 0;
const isMobile = windowWidth < 640;
return (
<>
<style jsx global>{`
.notifications-filter-scroll::-webkit-scrollbar {
display: none;
}
`}</style>
<Location />
<HeaderConfProvider />
<div style={{ padding: "20px 40px", maxWidth: "900px", margin: "0 auto", paddingBottom: "100px" }}>
<div
style={{
width: "100%",
boxSizing: "border-box",
padding: isMobile ? "18px 10px 90px" : "20px 40px 100px",
maxWidth: "900px",
margin: "0 auto",
position: "relative",
}}
>
{/* Header and Controls */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "30px", borderBottom: "1px solid #eaeaea", paddingBottom: "20px" }}>
<div>
<h1 style={{ margin: 0, fontSize: "28px", color: "var(--black-light)", display: "flex", alignItems: "center", gap: "10px" }}>
<NotificationsActiveIcon sx={{ color: "var(--wine-dark)" }} />
Bandeja de Entrada
</h1>
<p style={{ margin: "5px 0 0 0", color: "#666", fontSize: "14px" }}>
{unreadCount > 0 ? `Tienes ${unreadCount} notificación(es) sin leer.` : "No tienes notificaciones pendientes."}
</p>
</div>
<div style={{ display: "flex", gap: "10px" }}>
{/* Controls */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
flexDirection: "row",
gap: "10px",
marginBottom: isMobile ? 0 : "24px",
borderBottom: isMobile ? "none" : "1px solid #eaeaea",
paddingBottom: isMobile ? 0 : "20px",
position: isMobile ? "absolute" : "static",
top: isMobile ? "-44px" : "auto",
right: isMobile ? "10px" : "auto",
zIndex: 1,
}}
>
<div
style={{
display: "flex",
gap: "10px",
width: "auto",
justifyContent: "flex-end",
flexShrink: 0,
}}
>
{unreadCount > 0 && (
<button
onClick={() => setNotiState(true)}
@@ -423,7 +408,7 @@ export default function EditProfile() {
backgroundColor: "var(--wine-lighterX2)",
color: "var(--wine-darkest)",
border: "none",
padding: "8px 16px",
padding: isMobile ? "8px 14px" : "8px 16px",
borderRadius: "20px",
cursor: "pointer",
fontWeight: "bold",
@@ -441,7 +426,7 @@ export default function EditProfile() {
<IconButton
onClick={() => loadNotifications(notifications?.page, pageSize)}
color="secondary"
style={{ backgroundColor: "#f5f5f5" }}
style={{ backgroundColor: "#f5f5f5", boxShadow: isMobile ? "0 8px 20px rgba(0, 0, 0, 0.08)" : "none" }}
>
<ReloadIcon sx={{ color: "var(--black)" }} />
</IconButton>
@@ -450,7 +435,20 @@ export default function EditProfile() {
{/* Filters */}
{allDataList.length > 0 && (
<div style={{ display: "flex", gap: "10px", marginBottom: "25px", flexWrap: "wrap" }}>
<div
style={{
display: "flex",
gap: isMobile ? "8px" : "10px",
marginBottom: isMobile ? "18px" : "25px",
flexWrap: isMobile ? "nowrap" : "wrap",
overflowX: isMobile ? "auto" : "visible",
paddingBottom: isMobile ? "8px" : 0,
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
className="notifications-filter-scroll"
>
{[
{ id: "ALL", label: "Todas" },
{ id: "APPOINTMENT", label: "Turnos" },
@@ -463,7 +461,7 @@ export default function EditProfile() {
key={f.id}
onClick={() => setFilterType(f.id)}
style={{
padding: "8px 16px",
padding: isMobile ? "9px 14px" : "8px 16px",
borderRadius: "20px",
border: "1px solid",
borderColor: filterType === f.id ? "var(--wine-dark)" : "#eaeaea",
@@ -471,7 +469,8 @@ export default function EditProfile() {
color: filterType === f.id ? "#fff" : "#666",
cursor: "pointer",
fontWeight: filterType === f.id ? "bold" : "normal",
transition: "all 0.2s"
transition: "all 0.2s",
whiteSpace: "nowrap",
}}
>
{f.label}
@@ -485,7 +484,7 @@ export default function EditProfile() {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
style={{ textAlign: "center", padding: "40px 20px", backgroundColor: "#f9fafc", borderRadius: "15px", marginBottom: "30px" }}
style={{ textAlign: "center", padding: isMobile ? "34px 18px" : "40px 20px", backgroundColor: "#f9fafc", borderRadius: "15px", marginBottom: "30px" }}
>
<InboxIcon sx={{ fontSize: "64px", color: "var(--wine-light)", marginBottom: "15px" }} />
<h2 style={{ margin: 0, color: "var(--wine-darkest)" }}>¡Estás al día! 🌟</h2>
@@ -514,11 +513,13 @@ export default function EditProfile() {
style={{
backgroundColor: notif.readed ? "#ffffff" : "var(--wine-lighterX2)",
border: notif.readed ? "1px solid #eaeaea" : "1px solid var(--wine-light)",
borderRadius: "12px",
padding: "16px 20px",
borderRadius: isMobile ? "18px" : "12px",
padding: isMobile ? "14px" : "16px 20px",
display: "flex",
alignItems: "flex-start",
alignItems: isMobile ? "stretch" : "flex-start",
justifyContent: "space-between",
flexDirection: isMobile ? "column" : "row",
gap: isMobile ? "12px" : "8px",
boxShadow: notif.readed ? "none" : "0 4px 12px rgba(127, 42, 255, 0.1)",
transition: "all 0.2s ease",
cursor: notif.type && notif.code ? "pointer" : "default"
@@ -527,25 +528,35 @@ export default function EditProfile() {
onMouseOver={(e) => { e.currentTarget.style.transform = "translateY(-2px)"; e.currentTarget.style.boxShadow = "0 6px 16px rgba(0,0,0,0.08)"; }}
onMouseOut={(e) => { e.currentTarget.style.transform = "none"; e.currentTarget.style.boxShadow = notif.readed ? "none" : "0 4px 12px rgba(127, 42, 255, 0.1)"; }}
>
<div style={{ display: "flex", gap: "15px", alignItems: "flex-start", flex: 1 }}>
<div style={{ marginTop: "4px" }}>
<div style={{ display: "flex", gap: isMobile ? "12px" : "15px", alignItems: "flex-start", flex: 1, minWidth: 0 }}>
<div style={{ marginTop: "4px", flexShrink: 0 }}>
{notif.readed ?
<MarkReadIcon sx={{ color: "#ccc" }} /> :
<MarkUnReadIcon sx={{ color: "var(--wine-dark)" }} />
}
</div>
<div style={{ flex: 1 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
<h3 style={{ margin: 0, fontSize: "16px", fontWeight: notif.readed ? "normal" : "bold", color: notif.readed ? "#444" : "var(--wine-darkest)" }}>
<h3 style={{ margin: 0, fontSize: isMobile ? "15px" : "16px", lineHeight: "1.35", fontWeight: notif.readed ? "normal" : "bold", color: notif.readed ? "#444" : "var(--wine-darkest)", overflowWrap: "anywhere" }}>
{notif.subject}
</h3>
</div>
<p style={{ margin: 0, fontSize: "14px", color: notif.readed ? "#777" : "#333", lineHeight: "1.5" }}>
<p style={{ margin: 0, fontSize: isMobile ? "13px" : "14px", color: notif.readed ? "#777" : "#333", lineHeight: "1.5", overflowWrap: "anywhere" }}>
{notif.message}
</p>
</div>
</div>
<div style={{ display: "flex", gap: "5px", opacity: 0.8 }} onClick={(e) => e.stopPropagation()}>
<div
style={{
display: "flex",
gap: "5px",
opacity: 0.8,
justifyContent: isMobile ? "flex-end" : "flex-start",
borderTop: isMobile ? "1px solid #f0f0f0" : "none",
paddingTop: isMobile ? "8px" : 0,
}}
onClick={(e) => e.stopPropagation()}
>
<IconButton size="small" onClick={() => handleMarkRead(notif)} title={notif.readed ? "Marcar como no leída" : "Marcar como leída"}>
{notif.readed ? <MarkUnReadIcon fontSize="small" /> : <MarkReadIcon fontSize="small" color="primary" />}
</IconButton>