Adiciona notificações no header e página dedicada
This commit is contained in:
parent
1eac19177c
commit
29348e54ae
|
|
@ -12,6 +12,7 @@ import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
|||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||
import { Dashboard } from './pages/dashboard/dashboard';
|
||||
import { Notificacoes } from './pages/notificacoes/notificacoes';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
|
|
@ -24,6 +25,7 @@ export const routes: Routes = [
|
|||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
|
||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
|
||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
|
||||
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] },
|
||||
|
||||
// ✅ rota correta
|
||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export class AppComponent {
|
|||
'/vigencia',
|
||||
'/trocanumero',
|
||||
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
|
||||
'/notificacoes',
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -20,9 +20,49 @@
|
|||
</div>
|
||||
|
||||
<div class="logged-actions">
|
||||
<button type="button" class="btn-icon btn-bell" aria-label="Notificações">
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
<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>
|
||||
<span class="badge-dot" *ngIf="unreadCount > 0">{{ unreadCount }}</span>
|
||||
</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()">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CommonModule, isPlatformBrowser } from '@angular/common';
|
|||
import { PLATFORM_ID } from '@angular/core';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
|
|
@ -17,8 +18,12 @@ export class Header {
|
|||
|
||||
menuOpen = false;
|
||||
optionsOpen = false;
|
||||
notificationsOpen = false;
|
||||
isLoggedHeader = false;
|
||||
isHome = false;
|
||||
notifications: NotificationDto[] = [];
|
||||
notificationsLoading = false;
|
||||
notificationsError = false;
|
||||
|
||||
private readonly loggedPrefixes = [
|
||||
'/geral',
|
||||
|
|
@ -28,11 +33,13 @@ export class Header {
|
|||
'/vigencia',
|
||||
'/trocanumero',
|
||||
'/dashboard', // ✅ ADICIONADO
|
||||
'/notificacoes',
|
||||
];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
private notificationsService: NotificationsService,
|
||||
@Inject(PLATFORM_ID) private platformId: object
|
||||
) {
|
||||
// ✅ resolve no carregamento inicial
|
||||
|
|
@ -46,6 +53,7 @@ export class Header {
|
|||
this.syncHeaderState(rawUrl);
|
||||
this.menuOpen = false;
|
||||
this.optionsOpen = false;
|
||||
this.notificationsOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -69,15 +77,43 @@ export class Header {
|
|||
|
||||
toggleOptions() {
|
||||
this.optionsOpen = !this.optionsOpen;
|
||||
if (this.optionsOpen) this.notificationsOpen = false;
|
||||
}
|
||||
|
||||
closeOptions() {
|
||||
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() {
|
||||
this.authService.logout();
|
||||
this.optionsOpen = false;
|
||||
this.notificationsOpen = false;
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +126,7 @@ export class Header {
|
|||
@HostListener('document:click', [])
|
||||
onDocumentClick() {
|
||||
this.optionsOpen = false;
|
||||
this.notificationsOpen = false;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape', [])
|
||||
|
|
@ -97,5 +134,22 @@ export class Header {
|
|||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
this.closeMenu();
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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`, {});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue