feat: campana multi-módulo estilo inventario

This commit is contained in:
Nurfog committed 2026-09-16 10:02:22 -03:00
1 parent b1cddbac6c
commit e83bc239c0
5 files changed
+416 -6

No files matched your search

+1
View File
@@ -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)
+2 -4
View File
@@ -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 }) {
</div>
<div className="d-flex align-items-center gap-3 ms-auto">
<button className="notification-btn">
<i className="bi bi-bell"></i>
<span className="notification-badge">3</span>
</button>
<NotificationBell />
<TopNavbarUser />
</div>
+153 -2
View File
@@ -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);
@@ -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<string, { label: string; color: string; bg: string; icon: string }> = {
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<AppNotification[]>([]);
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 (
<div className="position-relative">
<button
className="notification-btn"
onClick={() => {
setOpen((o) => !o);
fetchAll();
}}
title="Notificaciones"
aria-label="Notificaciones"
>
<i className="bi bi-bell"></i>
{unread > 0 && (
<span className="notification-badge">{unread > 99 ? "99+" : unread}</span>
)}
</button>
{open && (
<>
<div
className="position-fixed top-0 start-0 w-100 h-100"
style={{ zIndex: 1000 }}
onClick={() => setOpen(false)}
/>
<div
className="dropdown-menu show position-absolute end-0 notification-panel"
style={{ zIndex: 1001 }}
>
<div className="notification-hero">
<div>
<span className="notification-eyebrow">Centro de notificaciones</span>
<strong>
{unread === 0 ? "Al día" : `${unread} sin leer`}
</strong>
</div>
<button
className="btn btn-sm notification-markall"
onClick={handleMarkAll}
disabled={markingAll || unread === 0}
>
{markingAll ? "Marcando..." : "Marcar leídas"}
</button>
</div>
{modules.length > 1 && (
<div className="notification-filters">
<button
className={`notification-pill ${filter === "" ? "active" : ""}`}
onClick={() => setFilter("")}
>
Todas
</button>
{modules.map((m) => {
const meta = moduleMeta(m);
return (
<button
key={m}
className={`notification-pill ${filter === m ? "active" : ""}`}
style={
filter === m
? { background: meta.color, borderColor: meta.color, color: "#fff" }
: undefined
}
onClick={() => setFilter(filter === m ? "" : m)}
>
<i className={`bi ${meta.icon}`}></i>
{meta.label}
</button>
);
})}
</div>
)}
<div className="notification-list">
{visible.length === 0 ? (
<div className="text-center text-muted py-4" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-bell-slash fs-4 d-block mb-2" style={{ opacity: 0.4 }}></i>
Sin notificaciones
</div>
) : (
visible.map((n) => {
const meta = moduleMeta(n.module ?? "");
return (
<button
key={n.id}
className={`notification-item ${n.isRead ? "" : "unread"}`}
style={!n.isRead ? { borderLeftColor: meta.color } : undefined}
onClick={() => handleOpenItem(n)}
>
<div className="notification-text">
<span
className="notification-module"
style={{ color: meta.color, background: meta.bg }}
>
<i className={`bi ${meta.icon}`}></i>
{meta.label}
</span>
<div className="notification-title">{n.title}</div>
{n.body && <div className="notification-body">{n.body}</div>}
<div className="notification-time">{timeAgo(n.createdAt)}</div>
</div>
</button>
);
})
)}
</div>
</div>
</>
)}
</div>
);
}
+42
View File
@@ -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<AppNotification[]>(`/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",
});
}