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", + }); +}