Adiciona notificações no header e página dedicada

This commit is contained in:
Eduardo Lopes 2026-01-22 15:34:00 -03:00
parent 1eac19177c
commit 29348e54ae
9 changed files with 476 additions and 3 deletions

View File

@ -12,6 +12,7 @@ import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia'; import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero'; import { TrocaNumero } from './pages/troca-numero/troca-numero';
import { Dashboard } from './pages/dashboard/dashboard'; import { Dashboard } from './pages/dashboard/dashboard';
import { Notificacoes } from './pages/notificacoes/notificacoes';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
@ -24,6 +25,7 @@ export const routes: Routes = [
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] },
// ✅ rota correta // ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, { path: 'dashboard', component: Dashboard, canActivate: [authGuard] },

View File

@ -34,6 +34,7 @@ export class AppComponent {
'/vigencia', '/vigencia',
'/trocanumero', '/trocanumero',
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard '/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
'/notificacoes',
]; ];
constructor( constructor(

View File

@ -20,10 +20,50 @@
</div> </div>
<div class="logged-actions"> <div class="logged-actions">
<button type="button" class="btn-icon btn-bell" aria-label="Notificações"> <div class="notifications-menu" [class.open]="notificationsOpen" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-icon btn-bell"
aria-label="Notificações"
(click)="toggleNotifications()"
[attr.aria-expanded]="notificationsOpen"
>
<i class="bi bi-bell"></i> <i class="bi bi-bell"></i>
<span class="badge-dot" *ngIf="unreadCount > 0">{{ unreadCount }}</span>
</button> </button>
<div class="notifications-dropdown" *ngIf="notificationsOpen">
<div class="notifications-head">
<span>Notificações</span>
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver todas</a>
</div>
<div class="notifications-body">
<div class="notifications-state" *ngIf="notificationsLoading">
Carregando...
</div>
<div class="notifications-state warn" *ngIf="notificationsError">
Falha ao carregar notificações.
</div>
<div class="notifications-state" *ngIf="!notificationsLoading && !notificationsError && notifications.length === 0">
Nenhuma notificação por aqui.
</div>
<div class="notification-item" *ngFor="let n of notifications">
<span class="notification-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
</span>
<div class="notification-title">{{ n.titulo }}</div>
<div class="notification-message">{{ n.mensagem }}</div>
<button type="button" class="mark-read" (click)="markNotificationRead(n)">
{{ n.lida ? 'Lida' : 'Marcar como lida' }}
</button>
</div>
</div>
</div>
</div>
<div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()"> <div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()">
<button <button
type="button" type="button"

View File

@ -149,6 +149,126 @@
} }
} }
.notifications-menu {
position: relative;
display: flex;
align-items: center;
}
.badge-dot {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 800;
display: grid;
place-items: center;
box-shadow: 0 0 0 3px #fff;
}
.notifications-dropdown {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: min(360px, 82vw);
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 18px 40px rgba(0,0,0,0.12);
z-index: 1200;
}
.notifications-head {
padding: 12px 14px 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
color: rgba(17, 18, 20, 0.9);
}
.see-all {
font-size: 12px;
color: var(--brand-primary);
text-decoration: none;
font-weight: 700;
}
.notifications-body {
max-height: 320px;
overflow: auto;
padding: 6px 8px 10px;
}
.notifications-state {
padding: 12px;
font-weight: 700;
color: rgba(17, 18, 20, 0.6);
}
.notifications-state.warn {
color: #b45309;
}
.notification-item {
background: rgba(248, 249, 255, 0.9);
border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 10px;
}
.notification-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
color: #1f2937;
background: rgba(3, 15, 170, 0.12);
}
.notification-tag.warn {
background: rgba(227, 61, 207, 0.16);
color: #8b2a7d;
}
.notification-tag.danger {
background: rgba(239, 68, 68, 0.16);
color: #b91c1c;
}
.notification-title {
margin-top: 6px;
font-weight: 800;
color: rgba(17, 18, 20, 0.9);
}
.notification-message {
margin-top: 4px;
font-size: 12px;
color: rgba(17, 18, 20, 0.68);
line-height: 1.4;
}
.mark-read {
margin-top: 8px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.options-menu { .options-menu {
position: relative; position: relative;
display: flex; display: flex;

View File

@ -4,6 +4,7 @@ import { CommonModule, isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core'; import { PLATFORM_ID } from '@angular/core';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@ -17,8 +18,12 @@ export class Header {
menuOpen = false; menuOpen = false;
optionsOpen = false; optionsOpen = false;
notificationsOpen = false;
isLoggedHeader = false; isLoggedHeader = false;
isHome = false; isHome = false;
notifications: NotificationDto[] = [];
notificationsLoading = false;
notificationsError = false;
private readonly loggedPrefixes = [ private readonly loggedPrefixes = [
'/geral', '/geral',
@ -28,11 +33,13 @@ export class Header {
'/vigencia', '/vigencia',
'/trocanumero', '/trocanumero',
'/dashboard', // ✅ ADICIONADO '/dashboard', // ✅ ADICIONADO
'/notificacoes',
]; ];
constructor( constructor(
private router: Router, private router: Router,
private authService: AuthService, private authService: AuthService,
private notificationsService: NotificationsService,
@Inject(PLATFORM_ID) private platformId: object @Inject(PLATFORM_ID) private platformId: object
) { ) {
// ✅ resolve no carregamento inicial // ✅ resolve no carregamento inicial
@ -46,6 +53,7 @@ export class Header {
this.syncHeaderState(rawUrl); this.syncHeaderState(rawUrl);
this.menuOpen = false; this.menuOpen = false;
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false;
}); });
} }
@ -69,15 +77,43 @@ export class Header {
toggleOptions() { toggleOptions() {
this.optionsOpen = !this.optionsOpen; this.optionsOpen = !this.optionsOpen;
if (this.optionsOpen) this.notificationsOpen = false;
} }
closeOptions() { closeOptions() {
this.optionsOpen = false; this.optionsOpen = false;
} }
toggleNotifications() {
this.notificationsOpen = !this.notificationsOpen;
if (this.notificationsOpen) {
this.optionsOpen = false;
this.loadNotifications();
}
}
closeNotifications() {
this.notificationsOpen = false;
}
markNotificationRead(notification: NotificationDto) {
if (notification.lida) return;
this.notificationsService.markAsRead(notification.id).subscribe({
next: () => {
notification.lida = true;
notification.lidaEm = new Date().toISOString();
},
});
}
get unreadCount() {
return this.notifications.filter(n => !n.lida).length;
}
logout() { logout() {
this.authService.logout(); this.authService.logout();
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false;
this.router.navigate(['/']); this.router.navigate(['/']);
} }
@ -90,6 +126,7 @@ export class Header {
@HostListener('document:click', []) @HostListener('document:click', [])
onDocumentClick() { onDocumentClick() {
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false;
} }
@HostListener('document:keydown.escape', []) @HostListener('document:keydown.escape', [])
@ -97,5 +134,22 @@ export class Header {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.closeMenu(); this.closeMenu();
this.closeOptions(); this.closeOptions();
this.closeNotifications();
}
private loadNotifications() {
if (!isPlatformBrowser(this.platformId)) return;
this.notificationsLoading = true;
this.notificationsError = false;
this.notificationsService.list().subscribe({
next: (data) => {
this.notifications = data || [];
this.notificationsLoading = false;
},
error: () => {
this.notificationsError = true;
this.notificationsLoading = false;
},
});
} }
} }

View File

@ -0,0 +1,41 @@
<section class="notificacoes-page">
<div class="wrap">
<div class="container">
<div class="page-head">
<div>
<h2>Notificações</h2>
<p>Acompanhe vencimentos e avisos recentes.</p>
</div>
</div>
<div class="state" *ngIf="loading">Carregando notificações...</div>
<div class="state warn" *ngIf="!loading && error">Falha ao carregar notificações.</div>
<div class="state" *ngIf="!loading && !error && notifications.length === 0">
Nenhuma notificação encontrada.
</div>
<div class="notifications-grid" *ngIf="!loading && !error && notifications.length > 0">
<article class="notification-card" *ngFor="let n of notifications">
<div class="card-head">
<span class="tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
</span>
<span class="date">{{ n.data | date:'dd/MM/yyyy' }}</span>
</div>
<h3>{{ n.titulo }}</h3>
<p>{{ n.mensagem }}</p>
<div class="card-meta">
<span *ngIf="n.cliente">Cliente: {{ n.cliente }}</span>
<span *ngIf="n.linha">Linha: {{ n.linha }}</span>
</div>
<button type="button" class="mark-read" (click)="markAsRead(n)">
{{ n.lida ? 'Lida' : 'Marcar como lida' }}
</button>
</article>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,130 @@
:host {
display: block;
}
.notificacoes-page {
width: 100%;
}
.wrap {
padding: 24px 0 32px;
}
.container {
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 0 16px;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 20px;
h2 {
font-size: 24px;
font-weight: 800;
margin: 0 0 4px;
}
p {
margin: 0;
color: rgba(17, 18, 20, 0.6);
font-weight: 600;
}
}
.state {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(0,0,0,0.08);
font-weight: 700;
color: rgba(17, 18, 20, 0.6);
}
.state.warn {
color: #b45309;
}
.notifications-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.notification-card {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.08);
padding: 16px;
box-shadow: 0 18px 36px rgba(0,0,0,0.08);
h3 {
margin: 12px 0 8px;
font-size: 16px;
font-weight: 800;
color: rgba(17, 18, 20, 0.92);
}
p {
margin: 0 0 12px;
font-size: 13px;
color: rgba(17, 18, 20, 0.68);
}
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
background: rgba(3, 15, 170, 0.12);
color: #1f2937;
}
.tag.warn {
background: rgba(227, 61, 207, 0.16);
color: #8b2a7d;
}
.tag.danger {
background: rgba(239, 68, 68, 0.16);
color: #b91c1c;
}
.date {
font-size: 12px;
color: rgba(17, 18, 20, 0.55);
font-weight: 700;
}
.card-meta {
display: grid;
gap: 4px;
font-size: 12px;
color: rgba(17, 18, 20, 0.7);
font-weight: 700;
}
.mark-read {
margin-top: 12px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}

View File

@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
@Component({
selector: 'app-notificacoes',
standalone: true,
imports: [CommonModule],
templateUrl: './notificacoes.html',
styleUrls: ['./notificacoes.scss'],
})
export class Notificacoes implements OnInit {
notifications: NotificationDto[] = [];
loading = false;
error = false;
constructor(private notificationsService: NotificationsService) {}
ngOnInit(): void {
this.loadNotifications();
}
markAsRead(notification: NotificationDto) {
if (notification.lida) return;
this.notificationsService.markAsRead(notification.id).subscribe({
next: () => {
notification.lida = true;
notification.lidaEm = new Date().toISOString();
},
});
}
private loadNotifications() {
this.loading = true;
this.error = false;
this.notificationsService.list().subscribe({
next: (data) => {
this.notifications = data || [];
this.loading = false;
},
error: () => {
this.error = true;
this.loading = false;
},
});
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type NotificationTipo = 'AVencer' | 'Vencido';
export type NotificationDto = {
id: string;
tipo: NotificationTipo;
titulo: string;
mensagem: string;
data: string;
referenciaData?: string | null;
diasParaVencer?: number | null;
lida: boolean;
lidaEm?: string | null;
vigenciaLineId?: string | null;
cliente?: string | null;
linha?: string | null;
};
@Injectable({ providedIn: 'root' })
export class NotificationsService {
private readonly baseUrl = `${environment.apiUrl}/notifications`;
constructor(private http: HttpClient) {}
list(): Observable<NotificationDto[]> {
return this.http.get<NotificationDto[]>(this.baseUrl);
}
markAsRead(id: string): Observable<void> {
return this.http.patch<void>(`${this.baseUrl}/${id}/read`, {});
}
}