diff --git a/README.md b/README.md
index cf7679b..00ac8d6 100644
--- a/README.md
+++ b/README.md
@@ -82,5 +82,6 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
- `GET|POST /users` · `PUT /users/{id}` (admin)
- `GET|POST /sla` · `PUT /sla/{id}` (admin)
+- `GET /notifications?module=&limit=&unreadOnly=` · `GET /notifications/unread-count?module=` · `PUT /notifications/{id}/read` · `PUT /notifications/read-all?module=` (propias, estándar multi-módulo `mail.notifications`, campana con polling 60s + color por módulo) · `POST /notifications/send` (módulos con `X-Module` + `X-Api-Key`)
- `GET /email/templates` · `POST /email/templates` · `PUT /email/templates/{key}` · `GET /email/log?module=helpdesk&limit=` · `GET|POST /email/modules` · `DELETE /email/modules/{m}` (admin, banco único `mail.*`)
- `POST /api/correos` (route propio server-side → `POST /api/email/send` con `X-Module: helpdesk` + `X-Api-Key`) · `POST /helpdesk/email/inbound/sync` (admin, inbound Gmail sin cambios)
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index ef8a52a..a2c16b0 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useAuth } from "@/lib/auth";
import Sidebar from "@/components/Sidebar";
+import NotificationBell from "@/components/NotificationBell";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
@@ -60,10 +61,7 @@ function AppShell({ children }: { children: React.ReactNode }) {
-
+
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 34f9f91..af5bb97 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -208,18 +208,169 @@ body {
position: absolute;
top: 2px;
right: 2px;
- width: 18px;
+ min-width: 18px;
height: 18px;
+ padding: 0 4px;
background: #dc3545;
color: #fff;
font-size: 0.65rem;
- border-radius: 50%;
+ border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
+.notification-panel {
+ width: 380px;
+ max-width: calc(100vw - 2rem);
+ padding: 0;
+ overflow: hidden;
+ border: 1px solid rgba(22, 36, 61, 0.1);
+ border-radius: 14px;
+ box-shadow: 0 0.6rem 1.4rem rgba(22, 36, 61, 0.12);
+}
+
+.notification-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.8rem 1rem;
+ color: #fff;
+ background: linear-gradient(135deg, #16243d 0%, #015fc9 100%);
+}
+
+.notification-eyebrow {
+ display: block;
+ margin-bottom: 0.15rem;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.notification-hero strong {
+ font-size: 0.95rem;
+}
+
+.notification-markall {
+ flex-shrink: 0;
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ border-radius: 10px;
+ font-size: 0.75rem;
+}
+
+.notification-markall:hover:not(:disabled) {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.notification-markall:disabled {
+ color: rgba(255, 255, 255, 0.55);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.notification-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ padding: 0.6rem 1rem;
+ border-bottom: 1px solid #edf1f6;
+ background: #fbfcfe;
+}
+
+.notification-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ border: 1px solid #dfe8f2;
+ border-radius: 999px;
+ background: #fff;
+ color: #42506b;
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.28em 0.7em;
+}
+
+.notification-pill.active {
+ background: #16243d;
+ border-color: #16243d;
+ color: #fff;
+}
+
+.notification-list {
+ max-height: 380px;
+ overflow-y: auto;
+}
+
+.notification-item {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ border-bottom: 1px solid #edf1f6;
+ border-left: 4px solid transparent;
+ padding: 0.65rem 1rem;
+}
+
+.notification-item:last-child {
+ border-bottom: none;
+}
+
+.notification-item:hover {
+ background-color: #f0f6ff;
+}
+
+.notification-item.unread {
+ background-color: #e7f2ff;
+}
+
+.notification-text {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.notification-module {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border-radius: 6px;
+ padding: 0.15em 0.5em;
+ margin-bottom: 0.25rem;
+}
+
+.notification-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.notification-body {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ line-height: 1.35;
+ margin-top: 2px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.notification-time {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-top: 2px;
+}
+
/* Main Content */
.main-content {
margin-left: var(--sidebar-width);
diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx
new file mode 100644
index 0000000..812fe09
--- /dev/null
+++ b/frontend/src/components/NotificationBell.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ getNotifications,
+ getUnreadCount,
+ markNotificationRead,
+ markAllNotificationsRead,
+ type AppNotification,
+} from "@/lib/api";
+
+const POLL_MS = 60000;
+
+// Color por módulo (mismo lenguaje que inventario: pill suave + texto fuerte).
+const MODULE_META: Record = {
+ helpdesk: { label: "HelpDesk", color: "#015FC9", bg: "#e7f2ff", icon: "bi-headset" },
+ webpay: { label: "Webpay", color: "#198754", bg: "#e6f4ec", icon: "bi-credit-card" },
+ portal: { label: "Portal", color: "#6f42c1", bg: "#efe8f9", icon: "bi-grid" },
+};
+
+function moduleMeta(module: string) {
+ return (
+ MODULE_META[module] ?? {
+ label: module || "General",
+ color: "#6c788b",
+ bg: "#eef1f5",
+ icon: "bi-bell",
+ }
+ );
+}
+
+function timeAgo(iso: string): string {
+ const mins = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 60000));
+ if (mins < 1) return "ahora";
+ if (mins < 60) return `hace ${mins} min`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `hace ${hours} h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `hace ${days} d`;
+ return new Date(iso).toLocaleDateString("es-CL");
+}
+
+export default function NotificationBell() {
+ const router = useRouter();
+ const [items, setItems] = useState([]);
+ const [unread, setUnread] = useState(0);
+ const [open, setOpen] = useState(false);
+ const [filter, setFilter] = useState("");
+ const [markingAll, setMarkingAll] = useState(false);
+
+ const fetchAll = useCallback(() => {
+ Promise.all([getNotifications(50), getUnreadCount()])
+ .then(([list, { count }]) => {
+ setItems(list);
+ setUnread(count);
+ })
+ .catch(() => {
+ // Sin sesión o backend caído: se reintenta en el próximo ciclo.
+ });
+ }, []);
+
+ useEffect(() => {
+ fetchAll();
+ const timer = setInterval(fetchAll, POLL_MS);
+ const onFocus = () => fetchAll();
+ window.addEventListener("focus", onFocus);
+ return () => {
+ clearInterval(timer);
+ window.removeEventListener("focus", onFocus);
+ };
+ }, [fetchAll]);
+
+ const modules = [...new Set(items.map((n) => n.module).filter(Boolean))];
+ const visible = filter ? items.filter((n) => n.module === filter) : items;
+
+ const handleOpenItem = (n: AppNotification) => {
+ if (!n.isRead) {
+ markNotificationRead(n.id)
+ .then(() => {
+ setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, isRead: true } : x)));
+ setUnread((c) => Math.max(0, c - 1));
+ })
+ .catch(() => {});
+ }
+ setOpen(false);
+ if (n.ticketId) router.push(`/tickets/${n.ticketId}`);
+ };
+
+ const handleMarkAll = () => {
+ setMarkingAll(true);
+ markAllNotificationsRead(filter)
+ .then(() => {
+ setItems((prev) =>
+ prev.map((x) => (!filter || x.module === filter ? { ...x, isRead: true } : x))
+ );
+ if (!filter) {
+ setUnread(0);
+ } else {
+ getUnreadCount()
+ .then(({ count }) => setUnread(count))
+ .catch(() => {});
+ }
+ })
+ .catch(() => {})
+ .finally(() => setMarkingAll(false));
+ };
+
+ return (
+
+
+
+ {open && (
+ <>
+
setOpen(false)}
+ />
+
+
+
+ Centro de notificaciones
+
+ {unread === 0 ? "Al día" : `${unread} sin leer`}
+
+
+
+
+ {modules.length > 1 && (
+
+
+ {modules.map((m) => {
+ const meta = moduleMeta(m);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {visible.length === 0 ? (
+
+
+ Sin notificaciones
+
+ ) : (
+ visible.map((n) => {
+ const meta = moduleMeta(n.module ?? "");
+ return (
+
+ );
+ })
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 7cc3fa4..6f0c30b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -462,3 +462,45 @@ export async function syncInboundEmail() {
error?: string;
}>("/api/helpdesk/email/inbound/sync", { method: "POST" });
}
+
+// ==================== Notificaciones in-app (estándar multi-módulo) ====================
+export interface AppNotification {
+ id: number;
+ module: string;
+ ticketId?: number;
+ notifRef?: string;
+ type: string;
+ title: string;
+ body?: string;
+ isRead: boolean;
+ createdAt: string;
+}
+
+export async function getNotifications(limit = 50, module = "", unreadOnly = false) {
+ const query = new URLSearchParams({ limit: String(limit) });
+ if (module) query.set("module", module);
+ if (unreadOnly) query.set("unreadOnly", "true");
+ return fetchApi
(`/api/notifications?${query.toString()}`);
+}
+
+export async function getUnreadCount(module = "") {
+ const query = new URLSearchParams();
+ if (module) query.set("module", module);
+ const qs = query.toString();
+ return fetchApi<{ count: number }>(`/api/notifications/unread-count${qs ? `?${qs}` : ""}`);
+}
+
+export async function markNotificationRead(id: number) {
+ return fetchApi<{ id: number }>(`/api/notifications/${id}/read`, {
+ method: "PUT",
+ });
+}
+
+export async function markAllNotificationsRead(module = "") {
+ const query = new URLSearchParams();
+ if (module) query.set("module", module);
+ const qs = query.toString();
+ return fetchApi<{ updated: number }>(`/api/notifications/read-all${qs ? `?${qs}` : ""}`, {
+ method: "PUT",
+ });
+}