feat: campana multi-módulo estilo inventario
This commit is contained in:
1 parent
b1cddbac6c
commit
e83bc239c0
5 files changed
+416
-6
No files matched your search
@@ -82,5 +82,6 @@ Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
|||||||
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
|
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
|
||||||
- `GET|POST /users` · `PUT /users/{id}` (admin)
|
- `GET|POST /users` · `PUT /users/{id}` (admin)
|
||||||
- `GET|POST /sla` · `PUT /sla/{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.*`)
|
- `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)
|
- `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)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import NotificationBell from "@/components/NotificationBell";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
@@ -60,10 +61,7 @@ function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-3 ms-auto">
|
<div className="d-flex align-items-center gap-3 ms-auto">
|
||||||
<button className="notification-btn">
|
<NotificationBell />
|
||||||
<i className="bi bi-bell"></i>
|
|
||||||
<span className="notification-badge">3</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<TopNavbarUser />
|
<TopNavbarUser />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -208,18 +208,169 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
border-radius: 50%;
|
border-radius: 9px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 600;
|
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 */
|
||||||
.main-content {
|
.main-content {
|
||||||
margin-left: var(--sidebar-width);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -462,3 +462,45 @@ export async function syncInboundEmail() {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>("/api/helpdesk/email/inbound/sync", { method: "POST" });
|
}>("/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",
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user