feat: utilizando boas práticas de segurança

This commit is contained in:
Eduardo 2026-01-25 14:41:39 -03:00
parent 4972884b35
commit 4d357a4530
15 changed files with 1438 additions and 714 deletions

View File

@ -13,6 +13,7 @@ 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'; import { Notificacoes } from './pages/notificacoes/notificacoes';
import { NovoUsuario } from './pages/novo-usuario/novo-usuario';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
@ -26,6 +27,7 @@ export const routes: Routes = [
{ 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] }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] },
{ path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard] },
// ✅ rota correta // ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, { path: 'dashboard', component: Dashboard, canActivate: [authGuard] },

View File

@ -1,11 +1,10 @@
<header class="app-header" [class.scrolled]="isScrolled"> <header class="app-header" [class.scrolled]="isScrolled">
<div class="header-inner container"> <div class="header-inner container">
<!-- ✅ LOGADO: hambúrguer + logo JUNTOS -->
<ng-container *ngIf="isLoggedHeader; else publicHeader"> <ng-container *ngIf="isLoggedHeader; else publicHeader">
<div class="logged-header"> <div class="logged-header">
<div class="left-logged"> <div class="left-logged">
<button class="btn-icon" type="button" (click)="toggleMenu()" aria-label="Abrir menu"> <button class="btn-icon hamburger" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
<i class="bi bi-list"></i> <i class="bi bi-list"></i>
</button> </button>
@ -27,71 +26,96 @@
aria-label="Notificações" aria-label="Notificações"
(click)="toggleNotifications()" (click)="toggleNotifications()"
[attr.aria-expanded]="notificationsOpen" [attr.aria-expanded]="notificationsOpen"
[class.has-unread]="unreadCount > 0"
> >
<i class="bi bi-bell"></i> <i class="bi" [class.bi-bell-fill]="unreadCount > 0" [class.bi-bell]="unreadCount === 0"></i>
<span class="badge-dot" *ngIf="unreadCount > 0">{{ unreadCount }}</span> <span class="badge-pulse" *ngIf="unreadCount > 0"></span>
</button> </button>
<div class="notifications-dropdown" *ngIf="notificationsOpen"> <div class="notifications-dropdown" *ngIf="notificationsOpen">
<div class="notifications-head"> <div class="notifications-head">
<div class="head-title">
<span>Notificações</span> <span>Notificações</span>
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver todas</a> <span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span>
</div>
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver tudo</a>
</div> </div>
<div class="notifications-body"> <div class="notifications-body custom-scroll">
<div class="notifications-state" *ngIf="notificationsLoading"> <div class="notifications-state" *ngIf="notificationsLoading">
Carregando... <div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span>Carregando...</span>
</div> </div>
<div class="notifications-state warn" *ngIf="notificationsError"> <div class="notifications-state warn" *ngIf="notificationsError">
Falha ao carregar notificações. <i class="bi bi-exclamation-triangle"></i>
</div> <span>Falha ao carregar.</span>
<div class="notifications-state" *ngIf="!notificationsLoading && !notificationsError && notifications.length === 0">
Nenhuma notificação por aqui.
</div> </div>
<div class="notification-item" *ngFor="let n of notifications"> <div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notifications.length === 0">
<div class="notification-top"> <div class="empty-icon"><i class="bi bi-bell-slash"></i></div>
<span class="notification-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'"> <p>Tudo limpo por aqui!</p>
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
</span>
<span class="notification-line">{{ n.linha || '-' }}</span>
</div>
<div class="notification-title">
{{ n.linha || '-' }} - {{ n.usuario || n.cliente || '-' }}
</div>
<div class="notification-info">
<div><strong>Linha:</strong> {{ n.linha || '-' }}</div>
<div><strong>Usuário:</strong> {{ n.usuario || '-' }}</div>
<div><strong>Cliente:</strong> {{ n.cliente || '-' }}</div>
<div><strong>{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}:</strong> {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}</div>
</div>
<button type="button" class="mark-read" (click)="markNotificationRead(n)">
{{ n.lida ? 'Lida' : 'Marcar como lida' }}
</button>
</div>
</div> </div>
<div
class="notification-item"
*ngFor="let n of notifications"
[class.unread]="!n.lida"
(click)="markNotificationRead(n)"
>
<div class="notif-icon-area">
<div class="icon-circle" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
<i class="bi" [class.bi-x-lg]="n.tipo === 'Vencido'" [class.bi-clock-history]="n.tipo === 'AVencer'"></i>
</div> </div>
</div> </div>
<div class="notif-content">
<div class="notif-header">
<span class="notif-title">{{ n.linha || 'Sem Linha' }}</span>
<span class="notif-date">{{ n.referenciaData ? (n.referenciaData | date:'dd/MM') : '' }}</span>
</div>
<p class="notif-desc">
{{ n.tipo === 'Vencido' ? 'Venceu' : 'Vence em' }} - {{ n.cliente || 'Cliente não ident.' }}
</p>
<div class="notif-meta" *ngIf="n.usuario">
<i class="bi bi-person"></i> {{ n.usuario }}
</div>
</div>
<div class="notif-status" *ngIf="!n.lida" title="Marcar como lida">
<span class="status-dot"></span>
</div>
</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"
class="options-trigger" class="user-trigger"
(click)="toggleOptions()" (click)="toggleOptions()"
aria-haspopup="true" aria-haspopup="true"
[attr.aria-expanded]="optionsOpen" [attr.aria-expanded]="optionsOpen"
> >
Opções <div class="user-avatar">
<i class="bi bi-chevron-down"></i> <i class="bi bi-person-fill"></i>
</div>
<i class="bi bi-chevron-down chevron"></i>
</button> </button>
<div class="options-dropdown" *ngIf="optionsOpen"> <div class="options-dropdown" *ngIf="optionsOpen">
<button type="button" class="options-item" (click)="closeOptions()"> <div class="dropdown-arrow"></div>
Perfil <button type="button" class="options-item" *ngIf="isAdmin" (click)="openCreateUserModal()">
<i class="bi bi-person-plus"></i> Criar novo usuário
</button> </button>
<div class="divider"></div>
<button type="button" class="options-item" (click)="closeOptions()">
<i class="bi bi-person-circle"></i> Perfil
</button>
<div class="divider"></div>
<button type="button" class="options-item danger" (click)="logout()"> <button type="button" class="options-item danger" (click)="logout()">
Sair <i class="bi bi-box-arrow-right"></i> Sair
</button> </button>
</div> </div>
</div> </div>
@ -99,23 +123,16 @@
</div> </div>
</ng-container> </ng-container>
<!-- ✅ PÚBLICO (HOME): menu + botão -->
<ng-template #publicHeader> <ng-template #publicHeader>
<a routerLink="/" class="logo-area"> <a routerLink="/" class="logo-area">
<div class="logo-icon"> <div class="logo-icon"><i class="bi bi-layers-fill"></i></div>
<i class="bi bi-layers-fill"></i> <div class="logo-text">Line<span class="highlight">Gestão</span></div>
</div>
<div class="logo-text">
Line<span class="highlight">Gestão</span>
</div>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a> <a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a> <a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a> <a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a routerLink="/login" class="btn-login-header"> <a routerLink="/login" class="btn-login-header">
Acessar Sistema <i class="bi bi-arrow-right-short"></i> Acessar Sistema <i class="bi bi-arrow-right-short"></i>
@ -124,72 +141,134 @@
</ng-template> </ng-template>
</div> </div>
</header> </header>
<div class="toast-container position-fixed top-0 end-0 p-3"> <div class="modal-overlay" *ngIf="createUserOpen" (click)="closeCreateUserModal()"></div>
<div class="modal-card" *ngIf="createUserOpen" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Novo Usuário LineGestão</h3>
<button type="button" class="btn-icon close-x" (click)="closeCreateUserModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-alert error" *ngIf="createUserForbidden">
Voce nao tem permissao para criar usuarios.
</div>
<div class="form-alert error" *ngIf="!createUserForbidden && createUserErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of createUserErrors">
{{ err.message }}
</li>
</ul>
</div>
<div class="form-alert success" *ngIf="createUserSuccess">
{{ createUserSuccess }}
</div>
<form class="user-form" id="createUserForm" [formGroup]="createUserForm" (ngSubmit)="submitCreateUser()">
<div class="form-field" [class.has-error]="hasFieldError('nome') || (createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid)">
<label for="modalNome">Nome</label>
<input id="modalNome" type="text" placeholder="Nome completo" formControlName="nome" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid">Nome obrigatorio.</small>
<small class="field-error" *ngIf="getFieldErrors('nome').length">{{ getFieldErrors('nome')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('email') || (createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid)">
<label for="modalEmail">Email</label>
<input id="modalEmail" type="email" placeholder="nome@empresa.com" formControlName="email" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid">Email invalido.</small>
<small class="field-error" *ngIf="getFieldErrors('email').length">{{ getFieldErrors('email')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('senha') || (createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid)">
<label for="modalSenha">Senha</label>
<input id="modalSenha" type="password" placeholder="Defina uma senha segura" formControlName="senha" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid">Senha invalida.</small>
<small class="field-error" *ngIf="getFieldErrors('senha').length">{{ getFieldErrors('senha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('confirmarSenha') || (createUserForm.get('confirmarSenha')?.touched && createUserForm.get('confirmarSenha')?.invalid) || (passwordMismatch && createUserForm.get('confirmarSenha')?.touched)">
<label for="modalConfirmar">Confirmar Senha</label>
<input id="modalConfirmar" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="passwordMismatch && createUserForm.get('confirmarSenha')?.touched">As senhas nao conferem.</small>
<small class="field-error" *ngIf="getFieldErrors('confirmarSenha').length">{{ getFieldErrors('confirmarSenha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('permissao') || (createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid)">
<label for="modalPermissoes">Permissoes</label>
<select id="modalPermissoes" formControlName="permissao" [disabled]="createUserSubmitting">
<option value="" selected>Selecione o nivel</option>
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="operador">Operador</option>
<option value="leitura">Leitura</option>
</select>
<small class="field-error" *ngIf="createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid">Selecione uma permissao.</small>
<small class="field-error" *ngIf="getFieldErrors('permissao').length">{{ getFieldErrors('permissao')[0] }}</small>
</div>
</form>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" (click)="closeCreateUserModal()">Cancelar</button>
<button type="submit" form="createUserForm" class="btn-primary" [disabled]="createUserSubmitting || createUserForm.invalid">
<span *ngIf="!createUserSubmitting">Salvar</span>
<span *ngIf="createUserSubmitting">Salvando...</span>
</button>
</div>
</div>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000;">
<div class="toast notification-toast" #notifToast> <div class="toast notification-toast" #notifToast>
<div class="toast-header"> <div class="toast-header">
<strong class="me-auto">Vigência próxima</strong> <i class="bi bi-exclamation-circle-fill text-warning me-2"></i>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button> <strong class="me-auto">Atenção à Vigência</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div> </div>
<div class="toast-body" *ngIf="toastNotification as toastItem"> <div class="toast-body" *ngIf="toastNotification as toastItem">
A linha {{ toastItem.linha || '-' }} vence em 5 dias. A linha <strong>{{ toastItem.linha }}</strong> vence em breve.
<button type="button" class="btn-aware" (click)="acknowledgeNotification(toastItem)" data-bs-dismiss="toast"> <div class="mt-2 pt-2 border-top">
<button type="button" class="btn btn-sm btn-primary w-100" (click)="acknowledgeNotification(toastItem)" data-bs-dismiss="toast">
Estou ciente Estou ciente
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- ✅ OVERLAY (logado) -->
<div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div> <div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div>
<!-- ✅ MENU LATERAL (logado) --> <aside *ngIf="isLoggedHeader" class="side-menu" [class.open]="menuOpen" (click)="$event.stopPropagation()">
<aside
*ngIf="isLoggedHeader"
class="side-menu"
[class.open]="menuOpen"
(click)="$event.stopPropagation()"
>
<div class="side-menu-header"> <div class="side-menu-header">
<a class="side-logo" routerLink="/dashboard" (click)="closeMenu()"> <a class="side-logo" routerLink="/dashboard" (click)="closeMenu()">
<span class="side-logo-icon"><i class="bi bi-layers-fill"></i></span> <span class="side-logo-icon"><i class="bi bi-layers-fill"></i></span>
<span class="side-logo-text">Line<span class="highlight">Gestão</span></span> <span class="side-logo-text">Line<span class="highlight">Gestão</span></span>
</a> </a>
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
<button type="button" class="close-btn" aria-label="Fechar menu" (click)="closeMenu()">
<i class="bi bi-x-lg"></i>
</button>
</div> </div>
<div class="side-menu-body"> <div class="side-menu-body">
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-bar-chart-fill"></i> <span>Dashboard</span> <i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</a> </a>
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim"></i> <span>Geral</span> <i class="bi bi-sim"></i> <span>Geral</span>
</a> </a>
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-table"></i> <span>Mureg</span> <i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
</a> </a>
<a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-receipt"></i> <span>Faturamento</span> <i class="bi bi-receipt"></i> <span>Faturamento</span>
</a> </a>
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar-check"></i> <span>Vigência</span>
</a>
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de Número</span>
</a>
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-person-lines-fill"></i> <span>Dados dos Usuários</span> <i class="bi bi-people-fill"></i> <span>Dados de Usuários</span>
</a>
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
</a>
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a> </a>
</div> </div>
</aside> </aside>

View File

@ -1,33 +1,32 @@
/* Variáveis de apoio */
$primary: #1c38c9;
$danger: #ef4444;
$warning: #f59e0b;
$text-main: #111827;
$text-muted: #6b7280;
$bg-light: #f3f4f6;
$border-color: rgba(0,0,0,0.06);
.app-header { .app-header {
position: fixed; position: fixed;
top: 0; top: 0; left: 0; width: 100%;
left: 0;
width: 100%;
z-index: 1000; z-index: 1000;
padding: 16px 0; padding: 14px 0;
transition: all 0.3s ease; background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(16px);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(0,0,0,0.05); border-bottom: 1px solid rgba(0,0,0,0.05);
transition: all 0.3s ease;
&.scrolled { &.scrolled {
padding: 12px 0; padding: 10px 0;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
} }
} }
.header-inner { .header-inner {
display: flex; display: flex; align-items: center; justify-content: space-between;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* ✅ LOGADO: hambúrguer + logo lado a lado */
.left-logged {
display: flex;
align-items: center;
gap: 12px;
} }
.logged-header { .logged-header {
@ -38,394 +37,448 @@
width: 100%; width: 100%;
} }
.logged-actions { /* --- LOGO & MENU --- */
display: flex; .left-logged { display: flex; align-items: center; gap: 16px; }
align-items: center;
gap: 10px; .btn-icon {
background: transparent;
border: none;
width: 40px; height: 40px;
border-radius: 50%;
display: grid; place-items: center;
cursor: pointer;
transition: background 0.2s;
color: $text-main;
&:hover { background: rgba(0,0,0,0.04); }
i { font-size: 20px; }
} }
/* Logo */
.logo-area { .logo-area {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center; text-decoration: none; color: #111827;
gap: 10px;
text-decoration: none;
color: var(--text-main);
transition: transform 0.2s ease;
.logo-icon { .logo-icon {
width: 38px; width: 36px; height: 36px;
height: 38px; background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
background: conic-gradient(
from 210deg,
#1c38c9 0deg,
#3555ff 90deg,
#e33dcf 180deg,
#ff6b6b 250deg,
#2ecc71 320deg,
#1c38c9 360deg
);
color: #fff; color: #fff;
border-radius: 50%; border-radius: 50%;
display: grid; display: grid; place-items: center;
place-items: center;
font-size: 18px; font-size: 18px;
flex: 0 0 auto; box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
box-shadow: 0 10px 24px rgba(28, 56, 201, 0.25);
} }
.logo-text { .logo-text {
font-size: 20px; font-size: 19px; font-weight: 700; letter-spacing: -0.5px;
font-weight: 800;
letter-spacing: -0.4px;
text-transform: lowercase;
.highlight { .highlight {
background: linear-gradient(90deg, #1c38c9 0%, #6a55ff 45%, #e33dcf 100%); background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; background-clip: text;
text-transform: none; color: transparent;
margin-left: 4px; }
font-weight: 700;
} }
} }
&:hover { .logged-actions {
transform: translateY(-1px);
}
}
/* Nav (Desktop) */
.nav-links {
display: flex;
gap: 32px;
@media(max-width: 992px) { display: none; }
.nav-link {
text-decoration: none;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
&:hover { color: var(--brand-primary); }
}
}
.header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
margin-left: auto;
} }
.btn-login-header { /* --- NOTIFICAÇÕES (Dropdown) --- */
text-decoration: none; .notifications-menu { position: relative; }
font-size: 14px;
font-weight: 600;
color: var(--text-main);
padding: 8px 20px;
border-radius: 99px;
background: #fff;
border: 1px solid rgba(0,0,0,0.1);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
transform: translateY(-1px);
}
}
.btn-icon {
background: rgba(255,255,255,0.75);
border: 1px solid rgba(0,0,0,0.10);
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
cursor: pointer;
backdrop-filter: blur(12px);
transition: transform 0.15s ease, box-shadow 0.15s ease;
i { font-size: 24px; color: var(--text-main); }
&:hover {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(0,0,0,0.12);
}
}
/* ✅ Opções (logado) */
.btn-bell { .btn-bell {
width: 42px;
height: 42px;
border-radius: 12px;
i {
font-size: 18px;
}
}
.notifications-menu {
position: relative; position: relative;
display: flex;
align-items: center; &.has-unread {
color: $primary;
background: rgba(28, 56, 201, 0.06);
} }
.badge-dot { .badge-pulse {
position: absolute; position: absolute;
top: -4px; top: 10px; right: 10px;
right: -4px; width: 8px; height: 8px;
min-width: 20px; background: $danger;
height: 20px; border-radius: 50%;
padding: 0 5px; border: 2px solid #fff;
border-radius: 999px; box-shadow: 0 0 0 0 rgba($danger, 0.7);
background: #ef4444; animation: pulse-red 2s infinite;
color: #fff; }
font-size: 11px; }
font-weight: 800;
display: grid; @keyframes pulse-red {
place-items: center; 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0.7); }
box-shadow: 0 0 0 3px #fff; 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba($danger, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0); }
} }
.notifications-dropdown { .notifications-dropdown {
position: absolute; position: absolute;
right: 0; top: calc(100% + 12px); right: -10px;
top: calc(100% + 8px); width: 340px;
width: min(360px, 82vw);
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.04);
box-shadow: 0 18px 40px rgba(0,0,0,0.12);
z-index: 1200; z-index: 1200;
transform-origin: top right;
animation: slideDown 0.2s ease-out;
overflow: hidden;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
} }
.notifications-head { .notifications-head {
padding: 12px 14px 8px; padding: 16px;
display: flex; border-bottom: 1px solid $border-color;
align-items: center; display: flex; align-items: center; justify-content: space-between;
justify-content: space-between; background: #fff;
font-weight: 800;
color: rgba(17, 18, 20, 0.9); .head-title {
border-bottom: 1px solid rgba(0,0,0,0.06); font-weight: 700; font-size: 15px; color: $text-main;
display: flex; align-items: center; gap: 8px;
}
.badge-count {
background: $danger; color: #fff;
font-size: 10px; padding: 2px 6px;
border-radius: 99px; font-weight: 800;
} }
.see-all { .see-all {
font-size: 12px; font-size: 12px; font-weight: 600; color: $primary;
color: var(--brand-primary);
text-decoration: none; text-decoration: none;
font-weight: 700; &:hover { text-decoration: underline; }
}
} }
.notifications-body { .notifications-body {
max-height: 320px; max-height: 380px;
overflow: auto; overflow-y: auto;
padding: 6px 8px 10px;
} }
.notifications-state { /* Scrollbar Bonito */
padding: 12px; .custom-scroll::-webkit-scrollbar { width: 5px; }
font-weight: 700; .custom-scroll::-webkit-scrollbar-track { background: transparent; }
color: rgba(17, 18, 20, 0.6); .custom-scroll::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 10px; }
} .custom-scroll::-webkit-scrollbar-thumb:hover { background: #d1d5db; }
.notifications-state.warn { .notifications-empty {
color: #b45309; padding: 40px 20px;
text-align: center;
color: $text-muted;
.empty-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.5; }
p { margin: 0; font-size: 13px; font-weight: 500; }
} }
/* Item da Notificação */
.notification-item { .notification-item {
background: rgba(248, 249, 255, 0.9); display: flex; gap: 12px;
border: 1px solid rgba(0,0,0,0.06); padding: 12px 16px;
border-radius: 12px; border-bottom: 1px solid $border-color;
padding: 10px 12px; cursor: pointer;
margin-bottom: 10px; transition: background 0.15s;
transition: transform 0.2s ease, box-shadow 0.2s ease; position: relative;
&:hover { &:hover { background: $bg-light; }
transform: translateY(-1px); &:last-child { border-bottom: none; }
box-shadow: 0 10px 20px rgba(0,0,0,0.08);
/* Estilo Não Lido */
&.unread {
background: rgba(28, 56, 201, 0.02);
&:hover { background: rgba(28, 56, 201, 0.05); }
.notif-title { color: $text-main; font-weight: 700; }
.status-dot {
width: 8px; height: 8px;
background: $primary;
border-radius: 50%;
display: block;
}
} }
} }
.notification-tag { .icon-circle {
display: inline-flex; width: 36px; height: 36px;
align-items: center; border-radius: 10px;
padding: 4px 8px; display: grid; place-items: center;
border-radius: 999px; background: #f3f4f6; color: $text-muted;
font-size: 11px; font-size: 16px;
font-weight: 800;
color: #1f2937; &.danger { background: rgba($danger, 0.1); color: $danger; }
background: rgba(3, 15, 170, 0.12); &.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); }
} }
.notification-top { .notif-content { flex: 1; min-width: 0; }
display: flex;
align-items: center; .notif-header {
justify-content: space-between; display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px;
gap: 10px;
} }
.notification-line { .notif-title { font-size: 13px; color: $text-main; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
font-weight: 800; .notif-date { font-size: 11px; color: $text-muted; }
font-size: 12px;
color: rgba(17, 18, 20, 0.65);
}
.notification-info { .notif-desc {
margin-top: 8px; margin: 0; font-size: 12px; color: $text-muted;
display: grid;
gap: 4px;
font-size: 12px;
color: rgba(17, 18, 20, 0.75);
strong {
color: rgba(17, 18, 20, 0.9);
}
}
.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; line-height: 1.4;
} }
.mark-read { .notif-meta {
margin-top: 8px; margin-top: 4px; font-size: 11px; color: rgba(0,0,0,0.4);
padding: 6px 10px; display: flex; align-items: center; gap: 4px;
border-radius: 10px; }
border: 1px solid rgba(0,0,0,0.08);
.notif-status {
display: flex; align-items: center; justify-content: center;
padding-left: 4px;
}
/* --- USER OPTIONS (Dropdown) --- */
.user-trigger {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px 4px 4px;
background: #fff; background: #fff;
font-size: 12px; border: 1px solid $border-color;
font-weight: 700; border-radius: 99px;
cursor: pointer; cursor: pointer;
color: rgba(17, 18, 20, 0.8); transition: all 0.2s;
&:hover { &:hover { border-color: rgba(0,0,0,0.2); box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
border-color: rgba(3, 15, 170, 0.35);
color: #030faa;
}
}
.notification-toast { .user-avatar {
border-radius: 14px; width: 32px; height: 32px;
border: 1px solid rgba(0,0,0,0.08); background: $bg-light;
box-shadow: 0 18px 36px rgba(0,0,0,0.16); border-radius: 50%;
display: grid; place-items: center;
color: $text-muted;
} }
.chevron { font-size: 10px; color: $text-muted; margin-right: 4px; }
.notification-toast .toast-header {
border-bottom: 1px solid rgba(0,0,0,0.06);
font-weight: 800;
}
.btn-aware {
display: inline-flex;
align-items: center;
margin-top: 10px;
padding: 6px 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.1);
background: #fff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
} }
.options-menu { .options-menu {
position: relative; position: relative; /* Essencial: Torna este o ponto de referência */
display: flex; display: flex;
align-items: center; align-items: center;
} }
.options-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
background: #fff;
font-weight: 700;
color: var(--text-main);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
i {
font-size: 12px;
}
&:hover {
border-color: rgba(227, 61, 207, 0.35);
box-shadow: 0 12px 22px rgba(0,0,0,0.08);
}
}
.options-dropdown { .options-dropdown {
position: absolute; position: absolute;
right: 0; top: 100%; /* Cola no final do botão */
top: calc(100% + 8px); right: 0; /* Alinha à direita do botão */
min-width: 200px; margin-top: 10px; /* Dá o espaçamento visual */
padding: 8px 0;
border-radius: 14px; width: 200px; /* Sugestão: um pouco mais largo para caber bem os textos */
border: 1px solid rgba(0,0,0,0.08);
background: #fff; background: #fff;
box-shadow: 0 18px 40px rgba(0,0,0,0.12); border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
padding: 6px;
z-index: 1200; z-index: 1200;
}
/* Animação suave (Opcional) */
transform-origin: top right;
animation: slideDown 0.2s ease-out;
.options-item { .options-item {
display: flex; width: 100%; text-align: left;
align-items: center; padding: 8px 12px;
width: 100%; background: transparent; border: none;
padding: 10px 16px; border-radius: 8px;
font-weight: 700; font-size: 13px; font-weight: 500; color: $text-main;
color: rgba(17, 18, 20, 0.85); display: flex; align-items: center; gap: 10px;
text-decoration: none;
background: transparent;
border: none;
cursor: pointer; cursor: pointer;
&:hover { &:hover { background: $bg-light; }
background: rgba(227, 61, 207, 0.08); &.danger { color: $danger; &:hover { background: rgba($danger, 0.05); } }
} }
&.danger { .divider { height: 1px; background: $border-color; margin: 4px 0; }
color: #c2410c; }
/* --- MODAL NOVO USUÁRIO --- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 1400;
}
.modal-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 32px));
background: #fff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
z-index: 1450;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid $border-color;
h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: $text-main;
} }
} }
/* ========================= */ .modal-body {
/* MENU LATERAL (LOGADO) */ padding: 18px 20px 10px;
/* ========================= */ }
.form-alert {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.4;
ul {
margin: 6px 0 0;
padding-left: 18px;
}
&.error {
background: rgba($danger, 0.08);
color: darken($danger, 5%);
border: 1px solid rgba($danger, 0.25);
}
&.success {
background: rgba(#22c55e, 0.1);
color: #15803d;
border: 1px solid rgba(#22c55e, 0.25);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 20px 18px;
}
.close-x {
width: 34px;
height: 34px;
}
.modal-card .user-form {
display: grid;
gap: 14px;
}
.modal-card .form-field {
display: grid;
gap: 6px;
label {
font-size: 13px;
font-weight: 600;
color: $text-main;
}
input,
select {
height: 42px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 14px;
color: $text-main;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus {
outline: none;
border-color: #2f6bff;
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15);
}
&.has-error input,
&.has-error select {
border-color: $danger;
box-shadow: 0 0 0 3px rgba($danger, 0.12);
}
}
.field-error {
font-size: 11px;
color: $danger;
}
.modal-card .btn-primary,
.modal-card .btn-secondary {
height: 40px;
min-width: 110px;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.modal-card .btn-primary {
background: #2f6bff;
color: #fff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2);
}
.modal-card .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.modal-card .btn-secondary {
background: #e2e8f0;
color: $text-main;
}
.modal-card .btn-primary:hover,
.modal-card .btn-secondary:hover {
transform: translateY(-1px);
}
@media (max-width: 768px) {
.modal-card {
width: min(520px, calc(100vw - 24px));
}
.modal-actions {
flex-direction: column;
align-items: stretch;
}
.modal-card .btn-primary,
.modal-card .btn-secondary {
width: 100%;
}
}
/* --- MENU LATERAL --- */
.menu-overlay { .menu-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1100; background: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.38); z-index: 1050;
backdrop-filter: blur(4px);
} }
.side-menu { .side-menu {
@ -433,129 +486,93 @@
top: 0; top: 0;
left: 0; left: 0;
height: 100vh; height: 100vh;
width: min(360px, 88vw); width: 280px;
z-index: 1150; background: #fff;
transform: translateX(-102%); box-shadow: 8px 0 24px rgba(0, 0, 0, 0.12);
transition: transform 240ms ease; transform: translateX(-100%);
transition: transform 0.25s ease;
background: rgba(255, 255, 255, 0.88); z-index: 1100;
backdrop-filter: blur(16px);
border-right: 1px solid rgba(227, 61, 207, 0.18);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.14);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.side-menu.open { transform: translateX(0); } .side-menu.open {
transform: translateX(0);
}
.side-menu-header { .side-menu-header {
padding: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; padding: 18px 16px;
border-bottom: 1px solid $border-color;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255,255,255,0.55);
} }
.side-logo { .side-logo {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
text-decoration: none; text-decoration: none;
color: var(--text-main); color: #111827;
}
.side-logo-icon { .side-logo-icon {
width: 38px; width: 34px;
height: 38px; height: 34px;
border-radius: 50%; border-radius: 50%;
background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
color: #fff;
display: grid; display: grid;
place-items: center; place-items: center;
color: #fff; font-size: 16px;
background: conic-gradient( box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
from 210deg,
#1c38c9 0deg,
#3555ff 90deg,
#e33dcf 180deg,
#ff6b6b 250deg,
#2ecc71 320deg,
#1c38c9 360deg
);
i { font-size: 18px; }
} }
.side-logo-text { .side-logo-text {
font-weight: 900; font-size: 16px;
font-size: 18px;
letter-spacing: -0.4px;
text-transform: lowercase;
.highlight {
background: linear-gradient(90deg, #1c38c9 0%, #6a55ff 45%, #e33dcf 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-transform: none;
margin-left: 4px;
font-weight: 700; font-weight: 700;
} letter-spacing: -0.4px;
.highlight {
background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
} }
.close-btn { .close-btn {
width: 42px; background: transparent;
height: 42px; border: none;
border-radius: 12px; width: 36px;
border: 1px solid rgba(0,0,0,0.10); height: 36px;
background: rgba(255, 255, 255, 0.70); border-radius: 50%;
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
color: $text-main;
i { font-size: 18px; color: rgba(17,18,20,0.7); } transition: background 0.2s;
&:hover { background: rgba(0, 0, 0, 0.06); }
} }
.side-menu-body { .side-menu-body {
padding: 12px; padding: 12px;
overflow: auto; display: flex;
flex-direction: column;
gap: 6px;
} }
.side-item { .side-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
width: 100%; padding: 10px 12px;
padding: 12px 12px; border-radius: 10px;
border-radius: 14px;
text-decoration: none; text-decoration: none;
color: rgba(17, 18, 20, 0.86); color: $text-main;
font-weight: 800; font-weight: 600;
font-family: 'Poppins', sans-serif; font-size: 14px;
transition: background 0.2s;
transition: background 180ms ease, transform 180ms ease; &:hover { background: $bg-light; }
&.active { background: rgba(28, 56, 201, 0.1); color: $primary; }
i {
font-size: 16px;
color: var(--brand-primary);
width: 18px;
text-align: center;
line-height: 1;
}
/* ✅ polimento: deixa o bar-chart com “peso” igual aos outros ícones */
.bi-bar-chart-fill {
font-size: 17px;
}
&:hover {
background: rgba(227, 61, 207, 0.10);
transform: translateY(-1px);
}
&.active {
background: rgba(3, 15, 170, 0.10);
border: 1px solid rgba(3, 15, 170, 0.12);
}
} }

View File

@ -5,11 +5,14 @@ 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'; import { NotificationsService, NotificationDto } from '../../services/notifications.service';
import { UsersService, CreateUserPayload, ApiFieldError } from '../../services/users.service';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
standalone: true, standalone: true,
imports: [RouterLink, CommonModule], imports: [RouterLink, CommonModule, ReactiveFormsModule],
templateUrl: './header.html', templateUrl: './header.html',
styleUrls: ['./header.scss'], styleUrls: ['./header.scss'],
}) })
@ -19,14 +22,22 @@ export class Header {
menuOpen = false; menuOpen = false;
optionsOpen = false; optionsOpen = false;
notificationsOpen = false; notificationsOpen = false;
createUserOpen = false;
isLoggedHeader = false; isLoggedHeader = false;
isHome = false; isHome = false;
isAdmin = false;
notifications: NotificationDto[] = []; notifications: NotificationDto[] = [];
notificationsLoading = false; notificationsLoading = false;
notificationsError = false; notificationsError = false;
private notificationsLoaded = false; private notificationsLoaded = false;
@ViewChild('notifToast') notifToast?: ElementRef; @ViewChild('notifToast') notifToast?: ElementRef;
createUserForm: FormGroup;
createUserSubmitting = false;
createUserErrors: ApiFieldError[] = [];
createUserForbidden = false;
createUserSuccess = '';
private readonly loggedPrefixes = [ private readonly loggedPrefixes = [
'/geral', '/geral',
'/mureg', '/mureg',
@ -36,16 +47,31 @@ export class Header {
'/trocanumero', '/trocanumero',
'/dashboard', // ✅ ADICIONADO '/dashboard', // ✅ ADICIONADO
'/notificacoes', '/notificacoes',
'/novo-usuario',
]; ];
constructor( constructor(
private router: Router, private router: Router,
private authService: AuthService, private authService: AuthService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private usersService: UsersService,
private fb: FormBuilder,
@Inject(PLATFORM_ID) private platformId: object @Inject(PLATFORM_ID) private platformId: object
) { ) {
this.createUserForm = this.fb.group(
{
nome: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
senha: ['', [Validators.required, Validators.minLength(6)]],
confirmarSenha: ['', [Validators.required, Validators.minLength(6)]],
permissao: ['', [Validators.required]],
},
{ validators: this.passwordsMatchValidator }
);
// ✅ resolve no carregamento inicial // ✅ resolve no carregamento inicial
this.syncHeaderState(this.router.url); this.syncHeaderState(this.router.url);
this.syncPermissions();
// ✅ resolve em toda navegação // ✅ resolve em toda navegação
this.router.events this.router.events
@ -53,6 +79,7 @@ export class Header {
.subscribe((event) => { .subscribe((event) => {
const rawUrl = event.urlAfterRedirects || event.url; const rawUrl = event.urlAfterRedirects || event.url;
this.syncHeaderState(rawUrl); this.syncHeaderState(rawUrl);
this.syncPermissions();
this.menuOpen = false; this.menuOpen = false;
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false; this.notificationsOpen = false;
@ -75,6 +102,14 @@ export class Header {
); );
} }
private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) {
this.isAdmin = false;
return;
}
this.isAdmin = this.authService.hasRole('admin');
}
toggleMenu() { toggleMenu() {
this.menuOpen = !this.menuOpen; this.menuOpen = !this.menuOpen;
} }
@ -92,6 +127,18 @@ export class Header {
this.optionsOpen = false; this.optionsOpen = false;
} }
openCreateUserModal() {
if (!this.isAdmin) return;
this.createUserOpen = true;
this.closeOptions();
this.resetCreateUserState();
}
closeCreateUserModal() {
this.createUserOpen = false;
this.resetCreateUserState();
}
toggleNotifications() { toggleNotifications() {
this.notificationsOpen = !this.notificationsOpen; this.notificationsOpen = !this.notificationsOpen;
if (this.notificationsOpen) { if (this.notificationsOpen) {
@ -122,6 +169,7 @@ export class Header {
this.authService.logout(); this.authService.logout();
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false; this.notificationsOpen = false;
this.isAdmin = false;
this.router.navigate(['/']); this.router.navigate(['/']);
} }
@ -143,6 +191,7 @@ export class Header {
this.closeMenu(); this.closeMenu();
this.closeOptions(); this.closeOptions();
this.closeNotifications(); this.closeNotifications();
this.closeCreateUserModal();
} }
acknowledgeNotification(notification: NotificationDto) { acknowledgeNotification(notification: NotificationDto) {
@ -206,4 +255,82 @@ export class Header {
return new Set<string>(); return new Set<string>();
} }
} }
submitCreateUser() {
if (this.createUserSubmitting) return;
if (this.createUserForm.invalid) {
this.createUserForm.markAllAsTouched();
return;
} }
if (!this.isAdmin) {
this.createUserForbidden = true;
return;
}
this.createUserSubmitting = true;
this.createUserErrors = [];
this.createUserForbidden = false;
this.createUserSuccess = '';
const payload = this.createUserForm.value as CreateUserPayload;
this.usersService.create(payload).subscribe({
next: (created) => {
this.createUserSubmitting = false;
this.createUserSuccess = `Usuario ${created.nome} criado com sucesso.`;
this.createUserForm.reset({ permissao: '' });
},
error: (err: HttpErrorResponse) => {
this.createUserSubmitting = false;
if (err.status === 401 || err.status === 403) {
this.createUserForbidden = true;
return;
}
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.createUserErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao criar usuario.',
}));
} else {
this.createUserErrors = [{ message: 'Erro ao criar usuario.' }];
}
},
});
}
hasFieldError(field: string): boolean {
return this.getFieldErrors(field).length > 0;
}
getFieldErrors(field: string): string[] {
const key = this.normalizeField(field);
return this.createUserErrors
.filter((e) => this.normalizeField(e.field) === key)
.map((e) => e.message);
}
get passwordMismatch(): boolean {
return !!this.createUserForm.errors?.['passwordsMismatch'];
}
private resetCreateUserState() {
this.createUserErrors = [];
this.createUserForbidden = false;
this.createUserSuccess = '';
this.createUserSubmitting = false;
this.createUserForm.reset({ permissao: '' });
}
private normalizeField(field?: string | null): string {
return (field || '').trim().toLowerCase();
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const senha = group.get('senha')?.value;
const confirmar = group.get('confirmarSenha')?.value;
if (!senha || !confirmar) return null;
return senha === confirmar ? null : { passwordsMismatch: true };
}
}

View File

@ -1,10 +1,12 @@
import { inject, PLATFORM_ID } from '@angular/core'; import { inject, PLATFORM_ID } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => { export const authGuard: CanActivateFn = () => {
const router = inject(Router); const router = inject(Router);
const platformId = inject(PLATFORM_ID); const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
// SSR: não existe localStorage. Bloqueia e manda pro login. // SSR: não existe localStorage. Bloqueia e manda pro login.
if (!isPlatformBrowser(platformId)) { if (!isPlatformBrowser(platformId)) {
@ -17,5 +19,12 @@ export const authGuard: CanActivateFn = () => {
return router.parseUrl('/login'); return router.parseUrl('/login');
} }
const payload = authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
localStorage.removeItem('token');
return router.parseUrl('/login');
}
return true; return true;
}; };

View File

@ -112,6 +112,15 @@ export class LoginComponent {
console.log('🔑 Token encontrado. Salvando...'); console.log('🔑 Token encontrado. Salvando...');
this.saveToken(token); this.saveToken(token);
const payload = this.authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
this.apiError = 'Token invalido: tenantId ausente.';
this.authService.logout();
this.isSubmitting = false;
return;
}
// VERIFICAÇÃO 2: Decodificação // VERIFICAÇÃO 2: Decodificação
try { try {
const nome = this.getNameFromToken(token); const nome = this.getNameFromToken(token);
@ -146,3 +155,5 @@ export class LoginComponent {
return !!(control.touched && control.invalid); return !!(control.touched && control.invalid);
} }
} }

View File

@ -1,81 +1,110 @@
<section class="notificacoes-page"> <section class="notificacoes-page">
<div class="wrap"> <div class="wrap">
<div class="container"> <div class="main-container">
<div class="page-head">
<div> <div class="page-header">
<h2>Notificações</h2> <div class="header-text">
<p>Acompanhe vencimentos e avisos recentes.</p> <h2>Central de Notificações</h2>
<p>Gerencie seus alertas de vencimento e avisos do sistema.</p>
</div> </div>
<div class="filters">
<button <div class="filters-bar">
type="button" <button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')">
class="filter-btn"
[class.active]="filter === 'todas'"
(click)="setFilter('todas')"
>
Todas Todas
</button> </button>
<button <button type="button" class="pill" [class.active]="filter === 'aVencer'" (click)="setFilter('aVencer')">
type="button"
class="filter-btn warning"
[class.active]="filter === 'aVencer'"
(click)="setFilter('aVencer')"
>
A vencer A vencer
<span class="count-badge" *ngIf="countByType('AVencer') > 0">{{ countByType('AVencer') }}</span>
</button> </button>
<button <button type="button" class="pill" [class.active]="filter === 'vencidas'" (click)="setFilter('vencidas')">
type="button"
class="filter-btn danger"
[class.active]="filter === 'vencidas'"
(click)="setFilter('vencidas')"
>
Vencidas Vencidas
<span class="count-badge danger" *ngIf="countByType('Vencido') > 0">{{ countByType('Vencido') }}</span>
</button> </button>
<button <button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')">
type="button" Arquivadas / Lidas
class="filter-btn neutral" </button>
[class.active]="filter === 'lidas'" </div>
(click)="setFilter('lidas')" </div>
<div class="state-container" *ngIf="loading">
<div class="spinner-border text-primary" role="status"></div>
<p>Atualizando...</p>
</div>
<div class="state-container error" *ngIf="!loading && error">
<i class="bi bi-wifi-off"></i>
<p>Não foi possível carregar as notificações.</p>
</div>
<div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0">
<div class="illustration">
<i class="bi bi-check-circle-fill"></i>
</div>
<h3>Tudo em dia!</h3>
<p *ngIf="filter === 'todas'">Você não tem nenhuma notificação pendente.</p>
<p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p>
</div>
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
<div class="list-header-actions">
<span>Mostrando {{ filteredNotifications.length }} notificações</span>
</div>
<div
class="list-item"
*ngFor="let n of filteredNotifications"
[class.is-read]="n.lida"
[class.is-danger]="n.tipo === 'Vencido'"
[class.is-warning]="n.tipo === 'AVencer'"
> >
Lidas <div class="status-strip"></div>
</button>
</div> <div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="n.tipo === 'Vencido'" [class.bi-clock-fill]="n.tipo === 'AVencer'"></i>
</div> </div>
<div class="state" *ngIf="loading">Carregando notificações...</div> <div class="item-content">
<div class="state warn" *ngIf="!loading && error">Falha ao carregar notificações.</div> <div class="content-top">
<div class="state" *ngIf="!loading && !error && notifications.length === 0"> <h4 class="item-title">
Nenhuma notificação encontrada. {{ n.linha || 'Linha Desconhecida' }}
</div> <span class="badge-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
<div class="state" *ngIf="!loading && !error && notifications.length > 0 && filteredNotifications.length === 0">
Nenhuma notificação para o filtro selecionado.
</div>
<div class="notifications-grid" *ngIf="!loading && !error && filteredNotifications.length > 0">
<article class="notification-card" *ngFor="let n of filteredNotifications">
<div class="card-head">
<span class="tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
</span> </span>
<span class="line-number">{{ n.linha || '-' }}</span> </h4>
<span class="item-time">
{{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
</span>
</div> </div>
<div class="card-title"> <p class="item-details">
{{ n.linha || '-' }} - {{ n.usuario || n.cliente || '-' }} <strong>Cliente:</strong> {{ n.cliente || '-' }} • <strong>Usuário:</strong> {{ n.usuario || '-' }}
</p>
<p class="item-message" *ngIf="n.tipo === 'Vencido'">
A vigência desta linha expirou. Verifique a renovação imediatamente.
</p>
<p class="item-message" *ngIf="n.tipo === 'AVencer'">
A vigência irá expirar em breve. Programe-se.
</p>
</div> </div>
<div class="card-info"> <div class="item-actions">
<div><strong>Linha:</strong> {{ n.linha || '-' }}</div> <button
<div><strong>Usuário:</strong> {{ n.usuario || '-' }}</div> type="button"
<div><strong>Cliente:</strong> {{ n.cliente || '-' }}</div> class="btn-action"
<div><strong>{{ n.tipo === 'Vencido' ? 'Venceu em' : 'Vence em' }}:</strong> {{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}</div> [title]="n.lida ? 'Já lida' : 'Marcar como lida'"
</div> (click)="markAsRead(n)"
[disabled]="n.lida"
<button type="button" class="mark-read" (click)="markAsRead(n)"> >
{{ n.lida ? 'Lida' : 'Marcar como lida' }} <i class="bi" [class.bi-check2-all]="n.lida" [class.bi-check2]="!n.lida"></i>
<span class="d-none d-md-inline">{{ n.lida ? 'Lida' : 'Marcar lida' }}</span>
</button> </button>
</article>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</section> </section>

View File

@ -1,178 +1,186 @@
:host { /* Variáveis */
display: block; $bg-page: #f9fafb;
} $white: #ffffff;
$primary: #1c38c9;
$danger: #ef4444;
$warning: #f59e0b;
$success: #10b981;
$text-main: #111827;
$text-secondary: #4b5563;
$border: #e5e7eb;
.notificacoes-page { :host { display: block; background-color: $bg-page; min-height: 100vh; }
width: 100%;
}
.wrap { .wrap { padding: 80px 0 40px; /* espaço para o header fixo */ }
padding: 24px 0 32px;
}
.container { .main-container {
width: 100%; max-width: 900px; /* Mais estreito para leitura melhor */
max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 0 16px; padding: 0 20px;
} }
.page-head { /* HEADER */
display: flex; .page-header {
align-items: center; margin-bottom: 32px;
justify-content: space-between; text-align: center; /* Centralizado fica mais moderno */
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
h2 { h2 { font-size: 28px; font-weight: 800; color: $text-main; margin-bottom: 8px; letter-spacing: -0.5px; }
font-size: 24px; p { color: $text-secondary; font-size: 16px; margin-bottom: 24px; }
font-weight: 800;
margin: 0 0 4px;
} }
p { /* FILTROS (Estilo Tabs/Pills) */
margin: 0; .filters-bar {
color: rgba(17, 18, 20, 0.6); display: inline-flex;
font-weight: 600; background: $white;
} padding: 6px;
border-radius: 99px;
box-shadow: 0 2px 10px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
gap: 4px;
flex-wrap: wrap; justify-content: center;
} }
.filters { .pill {
display: flex; border: none;
gap: 10px; background: transparent;
flex-wrap: wrap; padding: 8px 16px;
} border-radius: 99px;
font-size: 13px; font-weight: 600; color: $text-secondary;
.filter-btn {
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
padding: 8px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
color: rgba(17, 18, 20, 0.7);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s;
display: flex; align-items: center; gap: 6px;
&:hover { background: rgba(0,0,0,0.03); color: $text-main; }
&.active { &.active {
border-color: rgba(3, 15, 170, 0.4); background: $text-main; color: $white;
background: rgba(3, 15, 170, 0.08); box-shadow: 0 4px 12px rgba(0,0,0,0.15);
color: #030faa;
} }
&.warning.active { .count-badge {
border-color: rgba(227, 61, 207, 0.45); background: rgba(255,255,255,0.2);
background: rgba(227, 61, 207, 0.12); color: currentColor;
color: #8b2a7d; padding: 1px 6px; border-radius: 10px; font-size: 10px;
} &.danger { background: $danger; color: #fff; }
&.danger.active {
border-color: rgba(239, 68, 68, 0.45);
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
&.neutral.active {
border-color: rgba(15, 23, 42, 0.35);
background: rgba(15, 23, 42, 0.08);
color: #0f172a;
} }
} }
.state { /* STATES */
padding: 12px 14px; .state-container {
text-align: center; padding: 40px;
color: $text-secondary; font-weight: 500;
&.error { color: $danger; }
.spinner-border { margin-bottom: 12px; }
}
.empty-state-large {
text-align: center;
padding: 60px 20px;
.illustration {
font-size: 64px; color: $success; opacity: 0.2;
margin-bottom: 16px;
}
h3 { font-size: 20px; font-weight: 700; color: $text-main; }
p { color: $text-secondary; }
}
/* LISTA */
.notif-list {
display: flex; flex-direction: column; gap: 12px;
}
.list-header-actions {
font-size: 12px; font-weight: 600; color: $text-secondary; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 8px; padding-left: 8px;
}
.list-item {
background: $white;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(0,0,0,0.04);
border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 2px 4px rgba(0,0,0,0.02);
font-weight: 700; display: flex; align-items: flex-start;
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; padding: 16px;
box-shadow: 0 18px 36px rgba(0,0,0,0.08); position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease; overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 22px 44px rgba(0,0,0,0.12); box-shadow: 0 8px 16px rgba(0,0,0,0.06);
.btn-action { opacity: 1; }
} }
.card-info { /* Status Colors */
margin-top: 12px; &.is-danger .status-strip { background: $danger; }
display: grid; &.is-warning .status-strip { background: $warning; }
gap: 6px; &.is-read {
font-size: 13px; opacity: 0.7; background: #fcfcfc; box-shadow: none;
color: rgba(17, 18, 20, 0.72); .status-strip { background: $border; }
&:hover { opacity: 1; }
strong {
color: rgba(17, 18, 20, 0.92);
}
} }
} }
.card-title { .status-strip {
margin-top: 10px; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
font-weight: 800;
color: rgba(17, 18, 20, 0.92);
} }
.card-head { .item-icon {
display: flex; width: 40px; margin-left: 8px; margin-right: 16px;
align-items: center; font-size: 24px; display: flex; align-items: center; justify-content: center;
justify-content: space-between; height: 40px;
gap: 12px;
.bi-x-circle-fill { color: $danger; }
.bi-clock-fill { color: $warning; }
} }
.tag { .item-content { flex: 1; min-width: 0; }
display: inline-flex;
align-items: center; .content-top {
padding: 4px 10px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
border-radius: 999px; margin-bottom: 6px;
font-size: 11px;
font-weight: 800;
background: rgba(3, 15, 170, 0.12);
color: #1f2937;
} }
.tag.warn { .item-title {
background: rgba(227, 61, 207, 0.16); font-size: 16px; font-weight: 700; color: $text-main; margin: 0;
color: #8b2a7d; display: flex; align-items: center; gap: 8px;
} }
.tag.danger { .badge-tag {
background: rgba(239, 68, 68, 0.16); font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px;
color: #b91c1c; font-weight: 800; letter-spacing: 0.5px;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); }
} }
.line-number { .item-time { font-size: 12px; color: $text-secondary; font-weight: 500; }
font-size: 12px;
color: rgba(17, 18, 20, 0.55); .item-details {
font-weight: 700; font-size: 13px; color: $text-secondary; margin: 0 0 4px;
} }
.item-message {
font-size: 13px; color: $text-secondary; margin: 0; opacity: 0.8;
}
.mark-read { .item-actions {
margin-top: 12px; margin-left: 12px; align-self: center;
padding: 8px 12px; }
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08); .btn-action {
background: #fff; background: white; border: 1px solid $border;
font-size: 12px; padding: 8px 12px; border-radius: 8px;
font-weight: 700;
cursor: pointer; cursor: pointer;
color: $text-main; font-size: 13px; font-weight: 600;
display: flex; align-items: center; gap: 6px;
transition: all 0.2s;
&:hover { border-color: $primary; color: $primary; }
&:disabled { border-color: transparent; background: transparent; color: $success; cursor: default; }
/* Mobile optimization: show button usually only on hover desktop, always mobile */
@media(min-width: 768px) { opacity: 0.6; }
} }

View File

@ -63,4 +63,8 @@ export class Notificacoes implements OnInit {
}, },
}); });
} }
countByType(tipo: 'Vencido' | 'AVencer'): number {
return this.notifications.filter(n => n.tipo === tipo && !n.lida).length;
}
} }

View File

@ -0,0 +1,48 @@
<section class="create-user-page">
<div class="page-shell">
<div class="form-card">
<div class="form-header">
<h1>Novo Usuário LineGestão</h1>
<p>Preencha os dados para criar um novo usuário.</p>
</div>
<form class="user-form">
<div class="form-field">
<label for="nome">Nome</label>
<input id="nome" type="text" placeholder="Nome completo" />
</div>
<div class="form-field">
<label for="email">Email</label>
<input id="email" type="email" placeholder="nome@empresa.com" />
</div>
<div class="form-field">
<label for="senha">Senha</label>
<input id="senha" type="password" placeholder="Defina uma senha segura" />
</div>
<div class="form-field">
<label for="confirmarSenha">Confirmar Senha</label>
<input id="confirmarSenha" type="password" placeholder="Repita a senha" />
</div>
<div class="form-field">
<label for="permissoes">Permissões</label>
<select id="permissoes">
<option value="" selected>Selecione o nível</option>
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="operador">Operador</option>
<option value="leitura">Leitura</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary">Cancelar</button>
<button type="submit" class="btn-primary">Salvar</button>
</div>
</form>
</div>
</div>
</section>

View File

@ -0,0 +1,126 @@
/* Página Criar Novo Usuário */
.create-user-page {
min-height: calc(100vh - 69.2px);
padding: 32px 16px 80px;
background: radial-gradient(circle at 10% 15%, #e8f0ff 0%, #f6f7fb 45%, #ffffff 100%);
}
.page-shell {
max-width: 980px;
margin: 0 auto;
display: grid;
place-items: center;
}
.form-card {
width: min(720px, 100%);
background: #ffffff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
padding: 28px 28px 24px;
}
.form-header {
margin-bottom: 18px;
h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 6px;
}
p {
margin: 0;
color: #64748b;
font-size: 13px;
}
}
.user-form {
display: grid;
gap: 14px;
}
.form-field {
display: grid;
gap: 6px;
label {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
input,
select {
height: 42px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 14px;
color: #0f172a;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus {
outline: none;
border-color: #3b5bff;
box-shadow: 0 0 0 3px rgba(59, 91, 255, 0.15);
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 6px;
}
.btn-primary,
.btn-secondary {
height: 40px;
min-width: 110px;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary {
background: #2f6bff;
color: #ffffff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2);
}
.btn-secondary {
background: #e2e8f0;
color: #0f172a;
}
.btn-primary:hover,
.btn-secondary:hover {
transform: translateY(-1px);
}
@media (max-width: 768px) {
.form-card {
padding: 22px 20px 20px;
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-novo-usuario',
standalone: true,
imports: [CommonModule],
templateUrl: './novo-usuario.html',
styleUrls: ['./novo-usuario.scss'],
})
export class NovoUsuario {}

View File

@ -37,10 +37,53 @@ export class AuthService {
} }
get token(): string | null { get token(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('token'); return localStorage.getItem('token');
} }
isLoggedIn(): boolean { isLoggedIn(): boolean {
return !!this.token; return !!this.token;
} }
getTokenPayload(): Record<string, any> | null {
const token = this.token;
if (!token) return null;
try {
const payload = token.split('.')[1];
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(json);
} catch {
return null;
}
}
getRoles(): string[] {
const payload = this.getTokenPayload();
if (!payload) return [];
const possibleKeys = [
'role',
'roles',
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
];
let roles: string[] = [];
for (const key of possibleKeys) {
const value = payload[key];
if (!value) continue;
if (Array.isArray(value)) roles = roles.concat(value);
else roles.push(String(value));
}
return roles.map(r => r.toLowerCase());
}
hasRole(role: string): boolean {
const target = (role || '').toLowerCase();
if (!target) return false;
return this.getRoles().includes(target);
}
} }

View File

@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type UserPermission = 'admin' | 'gestor' | 'operador' | 'leitura';
export type UserDto = {
id: string;
nome: string;
email: string;
permissao: UserPermission;
tenantId: string;
ativo?: boolean;
};
export type ApiFieldError = {
field?: string | null;
message: string;
};
export type ApiErrorResponse = {
errors?: ApiFieldError[];
};
export type CreateUserPayload = {
nome: string;
email: string;
senha: string;
confirmarSenha: string;
permissao: UserPermission;
};
export type UsersListParams = {
search?: string;
permissao?: UserPermission;
page?: number;
pageSize?: number;
};
export type UpdateUserPayload = {
permissao: UserPermission;
ativo: boolean;
};
export type PagedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
@Injectable({ providedIn: 'root' })
export class UsersService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
create(payload: CreateUserPayload): Observable<UserDto> {
return this.http.post<UserDto>(`${this.baseApi}/users`, payload);
}
list(params: UsersListParams): Observable<PagedResult<UserDto>> {
let httpParams = new HttpParams();
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.permissao) httpParams = httpParams.set('permissao', params.permissao);
if (params.page) httpParams = httpParams.set('page', String(params.page));
if (params.pageSize) httpParams = httpParams.set('pageSize', String(params.pageSize));
return this.http.get<PagedResult<UserDto>>(`${this.baseApi}/users`, { params: httpParams });
}
update(id: string, payload: UpdateUserPayload): Observable<UserDto> {
return this.http.patch<UserDto>(`${this.baseApi}/users/${id}`, payload);
}
}

View File

@ -103,3 +103,134 @@ body {
height: auto !important; height: auto !important;
display: block !important; display: block !important;
} }
/* ========================================================== */
/* Modal Criar Usuário (forçar estilo global) */
/* ========================================================== */
app-header .modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 1400;
}
app-header .modal-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 32px));
background: #fff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
z-index: 1450;
display: flex;
flex-direction: column;
}
app-header .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
app-header .modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
app-header .modal-body {
padding: 18px 20px 10px;
}
app-header .modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 20px 18px;
}
app-header .modal-card .user-form {
display: grid;
gap: 14px;
}
app-header .modal-card .form-field {
display: grid;
gap: 6px;
}
app-header .modal-card .form-field label {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
app-header .modal-card .form-field input,
app-header .modal-card .form-field select {
height: 42px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 14px;
color: #0f172a;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
app-header .modal-card .form-field input:focus,
app-header .modal-card .form-field select:focus {
outline: none;
border-color: #2f6bff;
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15);
}
app-header .modal-card .btn-primary,
app-header .modal-card .btn-secondary {
height: 40px;
min-width: 110px;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
app-header .modal-card .btn-primary {
background: #2f6bff;
color: #fff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2);
}
app-header .modal-card .btn-secondary {
background: #e2e8f0;
color: #0f172a;
}
app-header .modal-card .btn-primary:hover,
app-header .modal-card .btn-secondary:hover {
transform: translateY(-1px);
}
@media (max-width: 768px) {
app-header .modal-card {
width: min(520px, calc(100vw - 24px));
}
app-header .modal-actions {
flex-direction: column;
align-items: stretch;
}
app-header .modal-card .btn-primary,
app-header .modal-card .btn-secondary {
width: 100%;
}
}