Skip to content

Commit dea4f54

Browse files
feat(notificacoes): página de notificações com badge na sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4501796 commit dea4f54

4 files changed

Lines changed: 226 additions & 4 deletions

File tree

src/frontend/src/App.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import ResetPassword from "./pages/ResetPassword";
99
import { isAuthenticated } from "./services/authService";
1010
import Dashboard from "./pages/Dashboard";
1111
import Emprestimos from "./pages/Emprestimos";
12+
import Notificacoes from "./pages/Notificacoes";
1213

1314
export default function App() {
1415
const location = useLocation();
@@ -56,6 +57,10 @@ export default function App() {
5657
path="/emprestimos"
5758
element={authenticated ? <Emprestimos /> : <Navigate to="/login" replace />}
5859
/>
60+
<Route
61+
path="/notificacoes"
62+
element={authenticated ? <Notificacoes /> : <Navigate to="/login" replace />}
63+
/>
5964
{/* <Route path="/configuracoes" element={<Configuracoes />} /> */}
6065
</Routes>
6166
</div>

src/frontend/src/components/Sidebar.jsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
import { useEffect, useState } from "react";
12
import { useLocation, useNavigate } from "react-router-dom";
23
import { getUsuarioLogado, logout } from "../services/authService";
4+
import { getNotificacoes } from "../services/NotificacoesService";
35

46
const navItems = [
5-
{ icon: "📊", label: "Dashboard", path: "/dashboard" },
6-
{ icon: "👥", label: "Clientes", path: "/clientes" },
7-
{ icon: "💳", label: "Empréstimos", path: "/emprestimos" },
8-
{ icon: "📈", label: "Relatórios", path: "/relatorios" },
7+
{ icon: "📊", label: "Dashboard", path: "/dashboard" },
8+
{ icon: "👥", label: "Clientes", path: "/clientes" },
9+
{ icon: "💳", label: "Empréstimos", path: "/emprestimos" },
10+
{ icon: "📈", label: "Relatórios", path: "/relatorios" },
11+
{ icon: "🔔", label: "Notificações", path: "/notificacoes" },
912
{ icon: "⚙️", label: "Configurações", path: "/configuracoes" },
1013
];
1114

1215
export default function Sidebar() {
1316
const location = useLocation();
1417
const navigate = useNavigate();
18+
const [naoLidas, setNaoLidas] = useState(0);
1519

1620
const usuario = getUsuarioLogado();
1721
const user = {
@@ -20,6 +24,13 @@ export default function Sidebar() {
2024
email: usuario?.email ?? "",
2125
};
2226

27+
useEffect(() => {
28+
if (!usuario?.nome) return;
29+
getNotificacoes(usuario.nome)
30+
.then((lista) => setNaoLidas(Array.isArray(lista) ? lista.filter((n) => !n.lida).length : 0))
31+
.catch(() => {});
32+
}, [usuario?.nome]);
33+
2334
function handleLogout() {
2435
logout();
2536
navigate("/login");
@@ -53,6 +64,9 @@ export default function Sidebar() {
5364
}}>
5465
{item.label}
5566
</span>
67+
{item.path === "/notificacoes" && naoLidas > 0 && (
68+
<span style={styles.notifBadge}>{naoLidas}</span>
69+
)}
5670
</button>
5771
);
5872
})}
@@ -197,6 +211,15 @@ const styles = {
197211
overflow: "hidden",
198212
textOverflow: "ellipsis",
199213
},
214+
notifBadge: {
215+
marginLeft: "auto",
216+
background: "#7C3AED",
217+
color: "#fff",
218+
borderRadius: 20,
219+
padding: "1px 7px",
220+
fontSize: 11,
221+
fontWeight: 700,
222+
},
200223
logoutBtn: {
201224
width: "100%",
202225
padding: "8px",
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { useEffect, useState } from "react";
2+
import { getUsuarioLogado } from "../services/authService";
3+
import { getNotificacoes, marcarLida, deletarNotif } from "../services/NotificacoesService";
4+
5+
const fmt = (v) =>
6+
Number(v ?? 0).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
7+
8+
const fmtData = (d) =>
9+
d ? new Date(d).toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
10+
11+
const TIPO_CONFIG = {
12+
Cobrança: { icon: "💳", cor: "#7C3AED", bg: "#EDE9FE" },
13+
Pagamento: { icon: "✅", cor: "#16A34A", bg: "#D1FAE5" },
14+
Vencimento: { icon: "⚠️", cor: "#D97706", bg: "#FEF3C7" },
15+
default: { icon: "🔔", cor: "#2563EB", bg: "#DBEAFE" },
16+
};
17+
18+
export default function Notificacoes() {
19+
const cobrador = getUsuarioLogado()?.nome ?? "";
20+
21+
const [lista, setLista] = useState([]);
22+
const [loading, setLoading] = useState(true);
23+
const [filtro, setFiltro] = useState("todas");
24+
25+
async function carregar() {
26+
setLoading(true);
27+
try {
28+
const data = await getNotificacoes(cobrador);
29+
setLista(Array.isArray(data) ? data : []);
30+
} catch {
31+
setLista([]);
32+
} finally {
33+
setLoading(false);
34+
}
35+
}
36+
37+
useEffect(() => { if (cobrador) carregar(); }, [cobrador]);
38+
39+
async function handleLida(id) {
40+
try {
41+
await marcarLida(id);
42+
setLista((prev) => prev.map((n) => n.id === id ? { ...n, lida: true } : n));
43+
} catch { /* silencioso */ }
44+
}
45+
46+
async function handleDeletar(id) {
47+
if (!confirm("Excluir esta notificação?")) return;
48+
try {
49+
await deletarNotif(id);
50+
setLista((prev) => prev.filter((n) => n.id !== id));
51+
} catch { /* silencioso */ }
52+
}
53+
54+
async function marcarTodas() {
55+
const naoLidas = lista.filter((n) => !n.lida);
56+
await Promise.all(naoLidas.map((n) => marcarLida(n.id)));
57+
setLista((prev) => prev.map((n) => ({ ...n, lida: true })));
58+
}
59+
60+
const naoLidas = lista.filter((n) => !n.lida).length;
61+
62+
const filtradas = lista.filter((n) => {
63+
if (filtro === "naoLidas") return !n.lida;
64+
if (filtro === "lidas") return n.lida;
65+
return true;
66+
});
67+
68+
return (
69+
<div style={s.page}>
70+
<div style={s.header}>
71+
<div>
72+
<h1 style={s.titulo}>
73+
Notificações
74+
{naoLidas > 0 && <span style={s.badge}>{naoLidas}</span>}
75+
</h1>
76+
<p style={s.sub}>{lista.length} notificação(ões) no total</p>
77+
</div>
78+
{naoLidas > 0 && (
79+
<button style={s.btnMarcar} onClick={marcarTodas}>
80+
✔ Marcar todas como lidas
81+
</button>
82+
)}
83+
</div>
84+
85+
{/* Filtros */}
86+
<div style={s.filtros}>
87+
{[
88+
{ key: "todas", label: "Todas" },
89+
{ key: "naoLidas", label: `Não lidas${naoLidas > 0 ? ` (${naoLidas})` : ""}` },
90+
{ key: "lidas", label: "Lidas" },
91+
].map((f) => (
92+
<button
93+
key={f.key}
94+
style={{ ...s.filtroBtn, ...(filtro === f.key ? s.filtroBtnAtivo : {}) }}
95+
onClick={() => setFiltro(f.key)}
96+
>
97+
{f.label}
98+
</button>
99+
))}
100+
</div>
101+
102+
{/* Lista */}
103+
{loading ? (
104+
<div style={s.vazio}>Carregando notificações...</div>
105+
) : filtradas.length === 0 ? (
106+
<div style={s.vazioBox}>
107+
<span style={{ fontSize: "2.5rem" }}>🔔</span>
108+
<p style={s.vazioTexto}>Nenhuma notificação encontrada.</p>
109+
</div>
110+
) : (
111+
<div style={s.lista}>
112+
{filtradas.map((n) => {
113+
const cfg = TIPO_CONFIG[n.tipo] ?? TIPO_CONFIG.default;
114+
return (
115+
<div key={n.id} style={{ ...s.card, opacity: n.lida ? 0.65 : 1, borderLeft: `4px solid ${cfg.cor}` }}>
116+
<div style={{ ...s.iconBox, background: cfg.bg, color: cfg.cor }}>
117+
{cfg.icon}
118+
</div>
119+
<div style={s.cardBody}>
120+
<div style={s.cardTop}>
121+
<span style={s.cardNome}>{n.clienteNome}</span>
122+
<span style={{ ...s.tipoBadge, background: cfg.bg, color: cfg.cor }}>{n.tipo}</span>
123+
</div>
124+
<p style={s.cardMsg}>{n.mensagem}</p>
125+
<div style={s.cardBottom}>
126+
<span style={s.cardData}>{fmtData(n.dataCriacao ?? n.data)}</span>
127+
{n.valor > 0 && <span style={s.cardValor}>{fmt(n.valor)}</span>}
128+
</div>
129+
</div>
130+
<div style={s.acoes}>
131+
{!n.lida && (
132+
<button style={s.btnLer} onClick={() => handleLida(n.id)} title="Marcar como lida"></button>
133+
)}
134+
<button style={s.btnDel} onClick={() => handleDeletar(n.id)} title="Excluir"></button>
135+
</div>
136+
{!n.lida && <span style={s.ponto} />}
137+
</div>
138+
);
139+
})}
140+
</div>
141+
)}
142+
</div>
143+
);
144+
}
145+
146+
const s = {
147+
page: { padding: "2rem", background: "#F5F3FF", minHeight: "100vh" },
148+
header: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.25rem" },
149+
titulo: { margin: 0, fontSize: "1.8rem", fontWeight: 700, color: "#1F2937", display: "flex", alignItems: "center", gap: 10 },
150+
badge: { background: "#7C3AED", color: "#fff", borderRadius: 20, padding: "2px 10px", fontSize: "0.85rem", fontWeight: 700 },
151+
sub: { margin: "0.25rem 0 0", color: "#6B7280", fontSize: "0.9rem" },
152+
btnMarcar: { background: "#7C3AED", color: "#fff", border: "none", borderRadius: 8, padding: "0.5rem 1.1rem", cursor: "pointer", fontWeight: 600, fontSize: "0.85rem" },
153+
filtros: { display: "flex", gap: 8, marginBottom: "1.25rem", flexWrap: "wrap" },
154+
filtroBtn: { background: "#fff", border: "1px solid #E5E7EB", borderRadius: 8, padding: "0.4rem 1rem", cursor: "pointer", fontSize: "0.85rem", color: "#6B7280" },
155+
filtroBtnAtivo:{ background: "#7C3AED", color: "#fff", border: "1px solid #7C3AED" },
156+
lista: { display: "flex", flexDirection: "column", gap: 10 },
157+
card: { background: "#fff", borderRadius: 12, padding: "1rem 1.25rem", display: "flex", alignItems: "flex-start", gap: "1rem", boxShadow: "0 1px 4px rgba(0,0,0,0.06)", position: "relative" },
158+
iconBox: { fontSize: "1.4rem", borderRadius: 10, width: 44, height: 44, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 },
159+
cardBody: { flex: 1, minWidth: 0 },
160+
cardTop: { display: "flex", alignItems: "center", gap: 8, marginBottom: 4 },
161+
cardNome: { fontWeight: 700, fontSize: "0.95rem", color: "#1F2937" },
162+
tipoBadge: { fontSize: "0.72rem", fontWeight: 600, borderRadius: 20, padding: "2px 8px" },
163+
cardMsg: { margin: "0 0 6px", fontSize: "0.85rem", color: "#4B5563" },
164+
cardBottom: { display: "flex", gap: 12, alignItems: "center" },
165+
cardData: { fontSize: "0.75rem", color: "#9CA3AF" },
166+
cardValor: { fontSize: "0.85rem", fontWeight: 700, color: "#7C3AED" },
167+
acoes: { display: "flex", flexDirection: "column", gap: 6, flexShrink: 0 },
168+
btnLer: { background: "#D1FAE5", color: "#16A34A", border: "none", borderRadius: 6, padding: "4px 8px", cursor: "pointer", fontSize: 13 },
169+
btnDel: { background: "#FEE2E2", color: "#DC2626", border: "none", borderRadius: 6, padding: "4px 8px", cursor: "pointer", fontSize: 13 },
170+
ponto: { position: "absolute", top: 12, right: 12, width: 8, height: 8, background: "#7C3AED", borderRadius: "50%" },
171+
vazio: { textAlign: "center", color: "#9CA3AF", padding: "3rem 0" },
172+
vazioBox: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, padding: "3rem 0" },
173+
vazioTexto: { color: "#9CA3AF", fontSize: "0.95rem" },
174+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getToken } from "./authService";
2+
3+
const BASE_URL = "http://localhost:5243/api/Notificacoes";
4+
5+
function headers() {
6+
return {
7+
"Content-Type": "application/json",
8+
Authorization: `Bearer ${getToken()}`,
9+
};
10+
}
11+
12+
async function req(url, options = {}) {
13+
const res = await fetch(url, { ...options, headers: headers() });
14+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
15+
return res.status === 204 ? null : res.json();
16+
}
17+
18+
export const getNotificacoes = (cobrador) => req(`${BASE_URL}/cobrador/${encodeURIComponent(cobrador)}`);
19+
export const marcarLida = (id) => req(`${BASE_URL}/${id}/lida`, { method: "PATCH" });
20+
export const deletarNotif = (id) => req(`${BASE_URL}/${id}`, { method: "DELETE" });

0 commit comments

Comments
 (0)