feat: refatorado o código envolvendo boas práticas saas e implementação da tela de relatórios

This commit is contained in:
Eduardo 2026-01-22 12:20:26 -03:00
parent 4bbf22152a
commit 3584d4a373
28 changed files with 3906 additions and 3371 deletions

View File

@ -1,5 +1,8 @@
<app-header></app-header> <app-header *ngIf="!isFullScreenPage"></app-header>
<router-outlet /> <main class="app-main" [class.has-header]="!isFullScreenPage">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer> <!-- ✅ Footer some em rotas logadas e também no login/register -->
<app-footer *ngIf="!hideFooter"></app-footer>

View File

@ -11,9 +11,7 @@ import { authGuard } from './guards/auth.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; 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 { Relatorios } from './pages/relatorios/relatorios';
// ✅ NOVO: TROCA DE NÚMERO
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
@ -25,9 +23,13 @@ export const routes: Routes = [
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] }, { path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
// ✅ NOVO: rota da página Troca de Número
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
// ✅ rota correta
{ path: 'relatorios', component: Relatorios, canActivate: [authGuard] },
// ✅ compatibilidade: se alguém acessar /portal/relatorios, manda pra /relatorios
{ path: 'portal/relatorios', redirectTo: 'relatorios', pathMatch: 'full' },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];

View File

@ -1,16 +1,65 @@
import { Component, signal } from '@angular/core'; // src/app/app.ts
import { RouterOutlet } from '@angular/router'; import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { Header } from './components/header/header'; import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
import { Footer } from './components/footer/footer'; import { CommonModule } from '@angular/common';
import { Header } from './components/header/header';
import { FooterComponent } from './components/footer/footer';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, Header, Footer], imports: [
CommonModule,
RouterOutlet,
Header,
FooterComponent
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrls: ['./app.scss'],
}) })
export class App { export class AppComponent {
protected readonly title = signal('line-gestao-frontend'); isFullScreenPage = false;
hideFooter = false;
// ✅ páginas que devem esconder header/footer (tela cheia)
private readonly fullScreenRoutes = ['/login', '/register'];
// ✅ rotas internas (LOGADO) que devem esconder footer
private readonly loggedPrefixes = [
'/geral',
'/mureg',
'/faturamento',
'/dadosusuarios',
'/vigencia',
'/trocanumero',
'/relatorios', // ✅ ADICIONADO: esconde footer na página de relatórios
];
constructor(
private router: Router,
@Inject(PLATFORM_ID) private platformId: object
) {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
const rawUrl = event.urlAfterRedirects || event.url;
// remove query/hash e barra final
let url = rawUrl.split('?')[0].split('#')[0];
url = url.replace(/\/+$/, '');
this.isFullScreenPage = this.fullScreenRoutes.includes(url);
const isLoggedRoute = this.loggedPrefixes.some(
(p) => url === p || url.startsWith(p + '/')
);
// ✅ footer some ao logar + também no login/register
this.hideFooter = isLoggedRoute || this.isFullScreenPage;
}
});
}
} }
// ✅ SSR espera importar { App } de './app/app'
export { AppComponent as App };

View File

@ -1,42 +1,32 @@
<footer class="footer-container"> <footer class="app-footer">
<div class="footer-line"></div>
<!-- COLUNA ESQUERDA (TEXTOS) --> <div class="container">
<div class="footer-left"> <div class="footer-inner">
<p>© 2024 Copyright | Line Móvel - Todos os Direitos Reservados</p>
<p><strong>Razão Social:</strong> LINE MÓVEL - SERVIÇOS E VENDAS EM TELECOMUNICAÇÕES</p> <div class="footer-brand">
<p><strong>CNPJ:</strong> 45.470.843/0001-90</p> <div class="logo-text">
<p>Av. Luís Viana Filho, Nº 7532 - Sala 1008</p> Line<span>Gestão</span>
</div> </div>
<p class="footer-tagline">
Inteligência para linhas corporativas.
</p>
</div>
<div class="footer-right"> <nav class="footer-nav">
<a href="https://www.linemovel.com.br/sobrenos" target="_blank">Sobre nós</a>
<a href="https://www.linemovel.com.br/empresas" target="_blank">Para Empresas</a>
<a href="#" class="disabled-link">Termos de Uso</a>
<a href="#" class="disabled-link">Privacidade</a>
</nav>
<div class="social-wrapper"> <div class="footer-copy">
<div class="social-section"> <p>
<span class="social-label">Siga-nos</span> &copy; {{ currentYear }} <strong>Ingline Systems</strong>.
<br class="d-md-none"> Todos os direitos reservados.
</p>
</div>
<a href="#" class="social-icon">
<i class="bi bi-instagram"></i>
</a>
<a href="#" class="social-icon">
<i class="bi bi-linkedin"></i>
</a>
</div> </div>
</div> </div>
</footer>
<div class="footer-button-wrapper">
<app-cta-button
label="Política de Privacidade"
width="248.84"
height="46.84"
background="rgba(201, 30, 181, 0.76)"
color="#FFFFFF"
fontSize="11"
fontWeight="700">
</app-cta-button>
</div>
</div>
</footer>

View File

@ -1,145 +1,102 @@
/* ===================================== */ .app-footer {
/* FOOTER CONTAINER VERSÃO MODERNA */ background-color: #fff;
/* ===================================== */ padding: 0 0 32px 0;
margin-top: auto; /* Garante que fique no fim se o conteúdo for curto */
position: relative;
font-family: 'Inter', sans-serif;
}
.footer-container { /* Linha fina com gradiente da marca no topo */
.footer-line {
width: 100%; width: 100%;
/* Degradê com as cores da marca */ height: 1px;
background: linear-gradient(90deg, #030FAA 0%, #6066FF 45%, #C91EB5 100%); background: linear-gradient(90deg,
padding: 10px 32px; /* bem mais baixo que antes */ rgba(255,255,255,0) 0%,
box-sizing: border-box; rgba(227, 61, 207, 0.3) 50%,
margin-top: -0.5px; rgba(255,255,255,0) 100%
);
margin-bottom: 32px;
}
.footer-inner {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
gap: 24px; gap: 24px;
font-family: "Inter", sans-serif; /* Responsividade: Empilha no mobile */
color: #FFFFFF; @media (max-width: 992px) {
border-top: 1px solid rgba(255, 255, 255, 0.12);
/* Suave sombra pra destacar do conteúdo */
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
/* Telas médias e abaixo empilha conteúdo */
@media (max-width: 1199.98px) {
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center;
text-align: center; text-align: center;
padding: 12px 20px; gap: 32px;
}
@media (max-width: 768px) {
padding: 12px 16px;
} }
} }
/* ===================================== */ /* Identidade */
/* LADO ESQUERDO (TEXTOS) */ .footer-brand {
/* ===================================== */ .logo-text {
.footer-left {
margin: 0; /* remove aqueles 100px enormes de antes */
}
.footer-left p {
margin: 0 0 2px 0; /* menos espaçamento vertical */
font-size: 13px;
font-weight: 500;
@media (max-width: 768px) {
font-size: 12px;
}
@media (max-width: 480px) {
font-size: 11.5px;
}
}
/* ===================================== */
/* LADO DIREITO (REDES + BOTÃO) */
/* ===================================== */
.footer-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
@media (max-width: 1199.98px) {
align-items: center;
}
@media (max-width: 768px) {
align-items: flex-start;
}
}
/* Redes sociais */
.social-wrapper {
width: 100%;
display: flex;
justify-content: flex-end;
@media (max-width: 1199.98px) {
justify-content: center;
}
@media (max-width: 768px) {
justify-content: flex-start;
}
}
.social-section {
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 480px) {
gap: 6px;
}
}
.social-label {
font-size: 13px;
font-weight: 500;
@media (max-width: 480px) {
font-size: 12px;
}
}
.social-icon i {
font-size: 20px;
color: #FFF;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
@media (max-width: 480px) {
font-size: 18px; font-size: 18px;
font-weight: 700;
color: var(--text-main, #0F172A);
margin-bottom: 4px;
span { color: #000; } /* ou mantenha a cor base */
}
.footer-tagline {
font-size: 13px;
color: var(--text-muted, #64748B);
margin: 0;
} }
} }
.social-icon i:hover { /* Navegação Central */
opacity: 0.8; .footer-nav {
transform: translateY(-1px);
}
/* Botão Política de Privacidade */
.footer-button-wrapper {
display: flex; display: flex;
justify-content: flex-end; gap: 24px;
@media (max-width: 1199.98px) { @media (max-width: 576px) {
justify-content: center; flex-direction: column;
gap: 12px;
} }
@media (max-width: 768px) { a {
justify-content: flex-start; text-decoration: none;
font-size: 13px;
font-weight: 500;
color: var(--text-muted, #64748B);
transition: color 0.2s ease;
&:hover {
color: var(--brand-primary, #E33DCF);
}
/* Estilo para links que ainda não existem (Termos/Privacidade) */
&.disabled-link {
cursor: default;
opacity: 0.6;
&:hover { color: var(--text-muted, #64748B); }
}
} }
} }
/* Copyright */
.footer-copy {
text-align: right;
@media (max-width: 992px) {
text-align: center;
}
p {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin: 0;
line-height: 1.5;
strong {
color: var(--text-main, #0F172A);
font-weight: 600;
}
}
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Footer } from './footer'; import { FooterComponent } from './footer';
describe('Footer', () => { describe('Footer', () => {
let component: Footer; let component: FooterComponent;
let fixture: ComponentFixture<Footer>; let fixture: ComponentFixture<FooterComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Footer] imports: [FooterComponent]
}) })
.compileComponents(); .compileComponents();
fixture = TestBed.createComponent(Footer); fixture = TestBed.createComponent(FooterComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@ -1,12 +1,13 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CtaButtonComponent } from '../cta-button/cta-button'; import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
imports: [CtaButtonComponent], standalone: true,
imports: [CommonModule],
templateUrl: './footer.html', templateUrl: './footer.html',
styleUrl: './footer.scss', styleUrls: ['./footer.scss']
}) })
export class Footer { export class FooterComponent {
currentYear: number = new Date().getFullYear();
} }

View File

@ -1,63 +1,58 @@
<header <header class="app-header" [class.scrolled]="isScrolled">
class="header-container" <div class="header-inner container">
[class.header-scrolled]="isScrolled"
>
<div class="header-top">
<!-- ESQUERDA: HAMBURGUER (logado) + LOGO -->
<div class="left-area">
<button
*ngIf="isLoggedHeader"
type="button"
class="hamburger-btn"
aria-label="Abrir menu"
(click)="toggleMenu()"
>
<i class="bi bi-list"></i>
</button>
<!-- ✅ Logo SEMPRE aparece no header --> <!-- ✅ LOGADO: hambúrguer + logo JUNTOS -->
<a class="logo-area" routerLink="/"> <ng-container *ngIf="isLoggedHeader; else publicHeader">
<img src="logo.png" alt="Logo" class="logo" /> <div class="left-logged">
<div class="logo-text ms-2"> <button class="btn-icon" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
<span class="line">Line</span><span class="gestao">Gestão</span> <i class="bi bi-list"></i>
</button>
<a routerLink="/geral" class="logo-area" (click)="closeMenu()">
<div class="logo-icon">
<i class="bi bi-layers-fill"></i>
</div>
<div class="logo-text">
Line<span class="highlight">Gestão</span>
</div>
</a>
</div>
</ng-container>
<!-- ✅ PÚBLICO (HOME): menu + botão -->
<ng-template #publicHeader>
<a routerLink="/" class="logo-area">
<div class="logo-icon">
<i class="bi bi-layers-fill"></i>
</div>
<div class="logo-text">
Line<span class="highlight">Gestão</span>
</div> </div>
</a> </a>
</div>
<!-- ✅ MENU HOME: só aparece fora do logado --> <nav class="nav-links">
<nav class="menu" *ngIf="!isLoggedHeader"> <a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
<a href="https://www.linemovel.com.br/sobrenos" class="menu-item" target="_blank">O que é a Line Móvel?</a> <a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
<a href="https://www.linemovel.com.br/empresas" class="menu-item" target="_blank">Para sua empresa</a> <a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
<a href="https://www.linemovel.com.br/proposta" class="menu-item" target="_blank">Solicite sua Proposta</a> </nav>
<a href="https://www.linemovel.com.br/indique" class="menu-item" target="_blank">Indique um amigo</a>
</nav>
<!-- ✅ BOTÕES: só aparecem fora do logado --> <div class="header-actions">
<div class="btn-area" *ngIf="!isLoggedHeader"> <a routerLink="/login" class="btn-login-header">
<button type="button" class="btn btn-cadastrar" [routerLink]="['/register']"> Acessar Sistema <i class="bi bi-arrow-right-short"></i>
Cadastre-se </a>
</button> </div>
</ng-template>
<button type="button" class="btn btn-login" [routerLink]="['/login']">
Login
</button>
</div>
</div> </div>
<!-- ✅ FAIXA (SÓ NA HOME) --> <!-- ✅ faixa (só na home, opcional) -->
<div class="header-bar footer-gradient" *ngIf="isHome"> <div class="header-bar" *ngIf="!isLoggedHeader && isHome">
<span class="header-bar-text"> <span class="header-bar-text">Somos a escolha certa para estar sempre conectado!</span>
Somos a escolha certa para estar sempre conectado!
</span>
</div> </div>
</header> </header>
<!-- ✅ OVERLAY (logado) --> <!-- ✅ OVERLAY (logado) -->
<div <div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div>
class="menu-overlay"
*ngIf="isLoggedHeader && menuOpen"
(click)="closeMenu()"
></div>
<!-- ✅ MENU LATERAL (logado) --> <!-- ✅ MENU LATERAL (logado) -->
<aside <aside
@ -67,82 +62,44 @@
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<div class="side-menu-header"> <div class="side-menu-header">
<!-- ✅ Logo DENTRO do menu lateral --> <a class="side-logo" routerLink="/geral" (click)="closeMenu()">
<a class="logo-area" routerLink="/" (click)="closeMenu()"> <span class="side-logo-icon"><i class="bi bi-layers-fill"></i></span>
<img src="logo.png" alt="Logo" class="logo" /> <span class="side-logo-text">Line<span class="highlight">Gestão</span></span>
<div class="logo-text ms-2">
<span class="line">Line</span><span class="gestao">Gestão</span>
</div>
</a> </a>
<button <button type="button" class="close-btn" aria-label="Fechar menu" (click)="closeMenu()">
type="button"
class="close-btn"
aria-label="Fechar menu"
(click)="closeMenu()"
>
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
</div> </div>
<div class="side-menu-body"> <div class="side-menu-body">
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a routerLink="/geral" class="side-item" (click)="closeMenu()"> <i class="bi bi-sim"></i> <span>Geral</span>
<i class="bi bi-sim me-2"></i> Gerenciar Linhas
</a> </a>
<!-- ✅ FATURAMENTO --> <a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a routerLink="/faturamento" class="side-item" (click)="closeMenu()"> <i class="bi bi-table"></i> <span>Mureg</span>
<i class="bi bi-receipt me-2"></i> Faturamento
</a> </a>
<!-- ✅ VIGÊNCIA --> <a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a routerLink="/vigencia" class="side-item" (click)="closeMenu()"> <i class="bi bi-receipt"></i> <span>Faturamento</span>
<i class="bi bi-calendar-check me-2"></i> Vigência
</a> </a>
<a routerLink="/mureg" class="side-item" (click)="closeMenu()"> <a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-table me-2"></i> Mureg <i class="bi bi-calendar-check"></i> <span>Vigência</span>
</a> </a>
<!-- ✅ TROCA DE NÚMERO --> <a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a routerLink="/trocanumero" class="side-item" (click)="closeMenu()"> <i class="bi bi-arrow-left-right"></i> <span>Troca de Número</span>
<i class="bi bi-arrow-left-right me-2"></i> Troca de Número
</a> </a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()"> <a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-clipboard-data me-2"></i> Controle de Contratos <i class="bi bi-person-lines-fill"></i> <span>Dados dos Usuários</span>
</a> </a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()"> <!-- ✅ CORRIGIDO + ESTILIZADO IGUAL AOS OUTROS -->
<i class="bi bi-people me-2"></i> Gerenciar Clientes <a routerLink="/relatorios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
</a> <i class="bi bi-bar-chart-fill"></i> <span>Relatórios</span>
<!-- ✅ DADOS DOS USUÁRIOS -->
<a routerLink="/dadosusuarios" class="side-item" (click)="closeMenu()">
<i class="bi bi-person-lines-fill me-2"></i> Dados dos Usuários
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-bar-chart me-2"></i> Relatórios
</a>
<hr class="my-2" />
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="side-item" (click)="closeMenu()">
<i class="bi bi-info-circle me-2"></i> O que é a Line Móvel?
</a>
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="side-item" (click)="closeMenu()">
<i class="bi bi-building me-2"></i> Para sua empresa
</a>
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="side-item" (click)="closeMenu()">
<i class="bi bi-file-earmark-text me-2"></i> Solicite sua Proposta
</a>
<a href="https://www.linemovel.com.br/indique" target="_blank" class="side-item" (click)="closeMenu()">
<i class="bi bi-megaphone me-2"></i> Indique um amigo
</a> </a>
</div> </div>
</aside> </aside>

View File

@ -1,333 +1,136 @@
:host { .app-header {
--brand: #E33DCF; position: fixed;
--blue: #030FAA;
--border: rgba(0, 0, 0, 0.10);
/* ✅ glass */
--glass: rgba(255, 255, 255, 0.35);
--glass-strong: rgba(255, 255, 255, 0.48);
--shadow-soft: 0 10px 26px rgba(0, 0, 0, 0.10);
}
/* ===================== */
/* HEADER PRINCIPAL */
/* ===================== */
.header-container {
width: 100%;
font-family: 'Inter', sans-serif;
display: flex;
flex-direction: column;
position: sticky;
top: 0; top: 0;
z-index: 1200; left: 0;
/* ✅ transparente/fosco */
background: var(--glass);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(227, 61, 207, 0.12);
transition: background 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
}
.header-container.header-scrolled {
background: var(--glass-strong);
box-shadow: var(--shadow-soft);
border-color: rgba(227, 61, 207, 0.18);
}
/* ===================== */
/* TOP AREA */
/* ===================== */
.header-top {
width: 100%; width: 100%;
height: 72px; z-index: 1000;
padding: 0 32px; padding: 16px 0;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0,0,0,0.05);
&.scrolled {
padding: 12px 0;
background: rgba(255, 255, 255, 0.92);
}
}
.header-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 22px; gap: 12px;
@media (max-width: 1200px) {
padding: 0 22px;
gap: 16px;
}
@media (max-width: 1024px) {
padding: 0 18px;
gap: 14px;
}
@media (max-width: 600px) {
height: auto;
padding: 12px 14px;
}
} }
/* ✅ Centralizar menu em telas grandes (desktop) */ /* ✅ LOGADO: hambúrguer + logo lado a lado */
@media (min-width: 993px) { .left-logged {
.header-top {
display: grid;
grid-template-columns: auto 1fr auto; /* esquerda | centro | direita */
align-items: center;
justify-content: unset;
}
.menu {
width: 100%;
justify-content: center; /* ✅ centraliza os links */
}
}
.left-area {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex: 0 0 auto;
}
/* ===================== */
/* HAMBURGUER (LOGADO) */
/* ===================== */
.hamburger-btn {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: grid;
place-items: center;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.hamburger-btn i {
color: var(--brand);
font-size: 22px;
}
.hamburger-btn:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.12);
border-color: rgba(227, 61, 207, 0.22);
}
.hamburger-btn:active {
transform: translateY(0) scale(0.99);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10);
}
/* ===================== */
/* LOGO */
/* ===================== */
.logo {
width: 44px;
height: 44px;
@media (max-width: 1280px) {
width: 38px;
height: 38px;
}
@media (max-width: 1024px) {
width: 30px;
height: 30px;
}
@media (max-width: 480px) {
width: 26px;
height: 26px;
}
} }
/* Logo */
.logo-area { .logo-area {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
text-decoration: none; text-decoration: none;
color: inherit; color: var(--text-main);
cursor: pointer;
transition: transform 180ms ease, filter 180ms ease; .logo-icon {
} width: 36px;
.logo-area:hover {
transform: translateY(-1px);
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.10));
}
/* TEXTO DA LOGO */
.logo-text {
white-space: nowrap;
}
.logo-text .line,
.logo-text .gestao {
font-weight: 800;
font-size: 32px;
@media (max-width: 1280px) { font-size: 26px; }
@media (max-width: 1100px) { font-size: 22px; }
@media (max-width: 1024px) { font-size: 18px; }
@media (max-width: 900px) { font-size: 17px; }
@media (max-width: 768px) { font-size: 16px; }
}
.logo-text .line { color: var(--blue); }
.logo-text .gestao { color: #000000; }
/* ===================== */
/* MENU (HOME) */
/* ===================== */
.menu {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
gap: 18px;
@media (max-width: 1280px) { gap: 14px; }
@media (max-width: 1100px) { gap: 12px; }
@media (max-width: 1024px) { gap: 10px; }
@media (max-width: 992px) {
display: none;
}
}
.menu-item {
font-family: 'Poppins', sans-serif;
font-size: 15px;
font-weight: 700;
color: rgba(0, 0, 0, 0.78) !important;
text-decoration: none !important;
padding: 10px 10px;
border-radius: 12px;
white-space: nowrap;
transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease;
@media (max-width: 1280px) { font-size: 13px; padding: 9px 9px; }
@media (max-width: 1100px) { font-size: 12.5px; padding: 8px 8px; }
@media (max-width: 1024px) { font-size: 12px; padding: 8px 8px; }
}
.menu-item:hover {
transform: translateY(-1px);
background: rgba(227, 61, 207, 0.08);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.08);
}
/* ===================== */
/* BOTÕES (HOME) */
/* ===================== */
.btn-area {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
@media (max-width: 1100px) { gap: 10px; }
}
.btn-cadastrar,
.btn-login {
width: 164px;
height: 41px;
border-radius: 14px;
font-size: 15px;
font-weight: 800;
border: 1px solid rgba(0, 0, 0, 0.10);
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease;
@media (max-width: 1280px) {
width: 150px;
height: 40px;
}
@media (max-width: 1100px) {
width: 140px;
height: 38px;
font-size: 14px;
}
@media (max-width: 1024px) {
width: 132px;
height: 36px; height: 36px;
font-size: 13px; background: linear-gradient(135deg, var(--brand-primary), #6A55FF);
color: #fff;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 18px;
flex: 0 0 auto;
} }
@media (max-width: 992px) { .logo-text {
width: 150px; font-size: 20px;
height: 40px; font-weight: 700;
letter-spacing: -0.5px;
.highlight { color: var(--text-main); }
}
}
/* 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-size: 14px;
} font-weight: 500;
transition: color 0.2s;
@media (max-width: 600px) { &:hover { color: var(--brand-primary); }
width: 46vw;
max-width: 190px;
} }
} }
.btn-cadastrar { .header-actions {
background: #E1E1E1; display: flex;
color: #000; align-items: center;
gap: 10px;
} }
.btn-login { .btn-login-header {
background: var(--brand); text-decoration: none;
border-color: var(--brand); font-size: 14px;
color: #fff !important; 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-cadastrar:hover, .btn-icon {
.btn-login:hover { background: rgba(255,255,255,0.75);
transform: translateY(-2px); border: 1px solid rgba(0,0,0,0.10);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.12); 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);
}
} }
.btn-login:hover { /* Faixa home */
filter: brightness(0.97);
}
/* ===================== */
/* FAIXA (HOME) */
/* ===================== */
.header-bar { .header-bar {
margin-top: 10px;
width: 100%; width: 100%;
height: 34px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@media (max-width: 480px) {
height: 30px;
}
}
.footer-gradient {
background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%); background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%);
} }
@ -336,38 +139,31 @@
font-size: 15px; font-size: 15px;
font-weight: 800; font-weight: 800;
font-family: 'Poppins', sans-serif; font-family: 'Poppins', sans-serif;
@media (max-width: 480px) { font-size: 13px; }
} }
/* ===================================================== */ /* ========================= */
/* MENU LATERAL (LOGADO) */ /* MENU LATERAL (LOGADO) */
/* ===================================================== */ /* ========================= */
.menu-overlay { .menu-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1100; z-index: 1100;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.38);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
} }
.side-menu { .side-menu {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100vh; height: 100vh;
width: min(340px, 88vw); width: min(360px, 88vw);
z-index: 1150; z-index: 1150;
transform: translateX(-102%); transform: translateX(-102%);
transition: transform 220ms ease; transition: transform 240ms ease;
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-right: 1px solid rgba(227, 61, 207, 0.18); border-right: 1px solid rgba(227, 61, 207, 0.18);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.14); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.14);
@ -385,31 +181,46 @@
gap: 12px; gap: 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255,255,255,0.55);
}
.side-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--text-main);
.side-logo-icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(135deg, var(--brand-primary), #6A55FF);
i { font-size: 18px; }
}
.side-logo-text {
font-weight: 900;
font-size: 18px;
letter-spacing: -0.4px;
.highlight { color: var(--text-main); }
}
} }
.close-btn { .close-btn {
width: 44px; width: 42px;
height: 44px; height: 42px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.10);
border: 1px solid rgba(0, 0, 0, 0.10); background: rgba(255, 255, 255, 0.70);
background: rgba(255, 255, 255, 0.60);
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.close-btn:hover { i { font-size: 18px; color: rgba(17,18,20,0.7); }
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.12);
}
.close-btn:active {
transform: translateY(0) scale(0.99);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10);
} }
.side-menu-body { .side-menu-body {
@ -420,55 +231,38 @@
.side-item { .side-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
padding: 12px 12px; padding: 12px 12px;
border-radius: 14px; border-radius: 14px;
text-decoration: none; text-decoration: none;
color: rgba(0, 0, 0, 0.80); color: rgba(17, 18, 20, 0.86);
font-weight: 800; font-weight: 800;
font-family: 'Poppins', sans-serif; font-family: 'Poppins', sans-serif;
transition: background 180ms ease, transform 180ms ease; transition: background 180ms ease, transform 180ms ease;
}
i {
.side-item i { color: var(--brand); } font-size: 16px;
color: var(--brand-primary);
.side-item:hover { width: 18px;
background: rgba(227, 61, 207, 0.10); text-align: center;
transform: translateY(-1px); line-height: 1;
} }
.side-item:active { /* ✅ polimento: deixa o bar-chart com “peso” igual aos outros ícones */
transform: translateY(0) scale(0.99); .bi-bar-chart-fill {
} font-size: 17px;
}
/* ========================================= */
/* OVERRIDE BOOTSTRAP */ &:hover {
/* ========================================= */ background: rgba(227, 61, 207, 0.10);
.btn.btn-cadastrar, transform: translateY(-1px);
.btn.btn-login { }
-webkit-tap-highlight-color: transparent;
} &.active {
background: rgba(3, 15, 170, 0.10);
.btn.btn-cadastrar:active, border: 1px solid rgba(3, 15, 170, 0.12);
.btn.btn-login:active, }
.btn.btn-cadastrar:active:focus,
.btn.btn-login:active:focus,
.btn.btn-cadastrar:focus,
.btn.btn-login:focus,
.btn.btn-cadastrar:focus-visible,
.btn.btn-login:focus-visible {
opacity: 1 !important;
filter: none !important;
}
.btn.btn-cadastrar:active { background: #E1E1E1 !important; }
.btn.btn-login:active { background: var(--brand) !important; border-color: var(--brand) !important; color: #fff !important; }
.btn.btn-cadastrar:active,
.btn.btn-login:active {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10) !important;
} }

View File

@ -1,8 +1,8 @@
// header.ts
import { Component, HostListener, Inject } from '@angular/core'; import { Component, HostListener, Inject } from '@angular/core';
import { RouterLink, Router, NavigationEnd } from '@angular/router'; import { RouterLink, Router, NavigationEnd } from '@angular/router';
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core'; import { PLATFORM_ID } from '@angular/core';
import { filter } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@ -13,12 +13,11 @@ import { PLATFORM_ID } from '@angular/core';
}) })
export class Header { export class Header {
isScrolled = false; isScrolled = false;
isHome = true;
menuOpen = false; menuOpen = false;
isLoggedHeader = false; isLoggedHeader = false;
isHome = false;
// ✅ rotas internas que usam menu lateral
private readonly loggedPrefixes = [ private readonly loggedPrefixes = [
'/geral', '/geral',
'/mureg', '/mureg',
@ -26,26 +25,34 @@ export class Header {
'/dadosusuarios', '/dadosusuarios',
'/vigencia', '/vigencia',
'/trocanumero', '/trocanumero',
'/relatorios', // ✅ ADICIONADO
]; ];
constructor( constructor(
private router: Router, private router: Router,
@Inject(PLATFORM_ID) private platformId: object @Inject(PLATFORM_ID) private platformId: object
) { ) {
this.router.events.subscribe((event) => { // ✅ resolve no carregamento inicial
if (event instanceof NavigationEnd) { this.syncHeaderState(this.router.url);
// ✅ resolve em toda navegação
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((event) => {
const rawUrl = event.urlAfterRedirects || event.url; const rawUrl = event.urlAfterRedirects || event.url;
const url = rawUrl.split('?')[0].split('#')[0]; this.syncHeaderState(rawUrl);
this.isHome = (url === '/' || url === '');
this.isLoggedHeader = this.loggedPrefixes.some((p) =>
url === p || url.startsWith(p + '/')
);
this.menuOpen = false; this.menuOpen = false;
} });
}); }
private syncHeaderState(rawUrl: string) {
const url = (rawUrl || '').split('?')[0].split('#')[0];
this.isHome = (url === '/' || url === '');
this.isLoggedHeader = this.loggedPrefixes.some((p) =>
url === p || url.startsWith(p + '/')
);
} }
toggleMenu() { toggleMenu() {

File diff suppressed because it is too large Load Diff

View File

@ -1,203 +1,113 @@
<section class="home-page"> <section class="home-wrapper">
<!-- BACKGROUND GLOBAL --> <div class="home-global-bg">
<span class="page-blob blob-1" aria-hidden="true"></span> <div class="blob blob-1"></div>
<span class="page-blob blob-2" aria-hidden="true"></span> <div class="blob blob-2"></div>
<span class="page-blob blob-3" aria-hidden="true"></span> <div class="mesh-overlay"></div>
<span class="page-blob blob-4" aria-hidden="true"></span> </div>
<!-- HERO --> <div class="content-scroll">
<section class="hero">
<div class="container"> <div class="container hero-container">
<div class="row align-items-center">
<div class="col-lg-6 hero-content fade-in-up">
<div class="badge-saas">
<span class="pulse"></span> Plataforma Inteligente
</div>
<h1 class="display-title">
Gestão de linhas corporativas <br>
<span class="text-gradient">simples e eficiente.</span>
</h1>
<p class="lead-text">
Substitua planilhas complexas por um painel unificado. Controle contratos, usuários e custos com a segurança que a <strong>Ingline Systems</strong> garante.
</p>
<div class="hero-inner"> <div class="hero-actions">
<button (click)="goToLogin()" class="btn-primary-saas">
<!-- COLUNA TEXTO (CENTRALIZADA) --> Acessar Painel
<div class="hero-copy"> </button>
<button (click)="scrollToFeatures()" class="btn-secondary-saas">
<div class="hero-badge" data-animate> Ver Recursos
<i class="bi bi-stars"></i> </button>
SaaS para gestão de linhas corporativas
</div> </div>
<section class="hero-text-section"> <div class="trust-strip">
<h1 class="main-title" data-animate> <span><i class="bi bi-shield-check"></i> Dados Seguros</span>
<span class="first-line">Gerencie suas linhas móveis</span> <span><i class="bi bi-lightning-charge"></i> Setup Rápido</span>
<span class="second-line">com <strong>inteligência e praticidade</strong></span> </div>
</h1>
<p class="main-paragraph" data-animate>
<strong class="brand-name">LineGestão</strong> é a solução completa para empresas que
<strong class="highlight">desejam controlar suas linhas móveis com eficiência e segurança</strong>.
Com recursos como <strong class="highlight">gerenciamento de clientes</strong>,
<strong class="highlight">importação de dados via Excel</strong> e
<strong class="highlight">monitoramento estratégico de contratos e linhas</strong>,
você <strong class="highlight">simplifica processos</strong>,
<strong class="highlight">reduz erros</strong> e
<strong class="highlight">ganha mais controle sobre seus recursos corporativos</strong>.
</p>
<div class="hero-actions" data-animate>
<app-cta-button
label="COMEÇAR AGORA"
(clicked)="iniciar()">
</app-cta-button>
<button type="button" class="cta-secondary" (click)="scrollToFeatures()">
<i class="bi bi-arrow-down-circle"></i>
Ver recursos
</button>
</div>
</section>
</div> </div>
<!-- CARD/MOCK (fica na direita) --> <div class="col-lg-6 fade-in-up" style="animation-delay: 0.2s;">
<div class="hero-mock" data-animate aria-label="Prévia visual do painel"> <div class="mockup-perspective">
<div class="mock-card"> <div class="mockup-card glass-card">
<div class="mock-top"> <div class="mock-header">
<span class="dot"></span><span class="dot"></span><span class="dot"></span> <div class="dots"><span></span><span></span><span></span></div>
<span class="mock-title">Visão Geral</span> <div class="bar">LineGestão / Dashboard</div>
</div>
<div class="mock-grid">
<div class="mock-kpi">
<span class="kpi-label">Linhas ativas</span>
<span class="kpi-value">128</span>
<span class="kpi-tag"><i class="bi bi-graph-up"></i> controle</span>
</div> </div>
<div class="mock-body">
<div class="mock-kpi"> <div class="kpi-row">
<span class="kpi-label">Contratos</span> <div class="kpi-box">
<span class="kpi-value">12</span> <small>Linhas Ativas</small>
<span class="kpi-tag"><i class="bi bi-file-earmark-text"></i> organizado</span> <strong>128</strong>
</div> </div>
<div class="kpi-box">
<div class="mock-kpi"> <small>Fatura Atual</small>
<span class="kpi-label">Clientes</span> <strong>R$ 4.2k</strong>
<span class="kpi-value">34</span> </div>
<span class="kpi-tag"><i class="bi bi-people"></i> centralizado</span> </div>
</div> <div class="fake-chart">
<div class="bar" style="height: 40%"></div>
<div class="mock-line"> <div class="bar" style="height: 60%"></div>
<div class="line-icon"><i class="bi bi-sim"></i></div> <div class="bar active" style="height: 85%"></div>
<div class="line-info"> <div class="bar" style="height: 55%"></div>
<div class="line-title">Linha 55XX9XXXXXXXX</div> </div>
<div class="line-sub">Status: Ativa • Operadora: Vivo</div> <div class="fake-list">
<div class="item"><span></span><div class="line"></div></div>
<div class="item"><span></span><div class="line"></div></div>
</div> </div>
<div class="line-pill">OK</div>
</div> </div>
</div> </div>
</div> <div class="floating-badge">
</div> <i class="bi bi-check-circle-fill"></i> Controle Total
</div>
<!-- NOVO: CENTRALIZAR OS CARDS NO MEIO -->
<div class="hero-metrics-wide" data-animate>
<div class="hero-metrics">
<div class="metric">
<i class="bi bi-lightning-charge"></i>
<div>
<span class="metric-title">Setup rápido</span>
<span class="metric-sub">comece em minutos</span>
</div>
</div>
<div class="metric">
<i class="bi bi-file-earmark-spreadsheet"></i>
<div>
<span class="metric-title">Excel → Sistema</span>
<span class="metric-sub">importação inteligente</span>
</div>
</div>
<div class="metric">
<i class="bi bi-shield-check"></i>
<div>
<span class="metric-title">Mais segurança</span>
<span class="metric-sub">menos erro manual</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> <section id="features" class="features-section">
<div class="container">
<!-- FEATURES --> <div class="text-center mb-5 fade-in-up">
<section id="features" class="features-section"> <h2 class="section-title">Tudo em um só lugar</h2>
<div class="container my-5"> <p class="section-subtitle">O essencial para eliminar erros manuais e ganhar tempo.</p>
<div class="section-head" data-animate>
<h2 class="section-title">
Tudo o que você precisa para <span class="brand">gestão de linhas</span>
</h2>
<p class="section-subtitle">
Um painel simples, bonito e direto ao ponto — feito para empresa que quer controle.
</p>
</div>
<!-- NOVO: Centralizar os cards -->
<div class="row justify-content-center feature-cards-row">
<div class="col-auto mb-4 feature-item" data-animate>
<app-feature-card
title="Monitoramento Completo"
[textAlign]="'center'"
iconClass="bi bi-laptop"
description="<strong>Acompanhe contratos, valores e suas linhas</strong> com visão estratégica, garantindo controle total sobre seus recursos móveis."
></app-feature-card>
</div> </div>
<div class="col-auto mb-4 feature-item" data-animate> <div class="grid-features fade-in-up">
<app-feature-card <div class="feature-box">
title="Gerenciamento de Clientes" <div class="icon-sq"><i class="bi bi-laptop"></i></div>
[textAlign]="'center'" <h3>Monitoramento</h3>
iconClass="bi bi-people" <p>Visão estratégica de contratos, valores e consumo em tempo real.</p>
description="<strong>Organize e acompanhe seus clientes</strong> com praticidade e segurança, garantindo uma gestão eficiente." </div>
></app-feature-card>
</div>
<div class="col-auto mb-4 feature-item" data-animate> <div class="feature-box">
<app-feature-card <div class="icon-sq"><i class="bi bi-file-earmark-spreadsheet"></i></div>
title="Importação via Excel" <h3>Importação Excel</h3>
[textAlign]="'center'" <p>Traga seus dados legados em segundos, substituindo planilhas manuais.</p>
iconClass="bi bi-table" </div>
description="<strong>Integre dados rapidamente</strong> sem esforço manual, substituindo planilhas por uma solução moderna e automatizada."
></app-feature-card>
</div>
</div> <div class="feature-box">
<div class="icon-sq"><i class="bi bi-people"></i></div>
<div class="value-strip" data-animate> <h3>Gestão de Usuários</h3>
<div class="value"> <p>Vincule linhas a colaboradores, organize centros de custo e evite desperdícios.</p>
<i class="bi bi-check2-circle"></i> </div>
<span><strong>Menos planilha</strong>, mais controle.</span>
</div>
<div class="value">
<i class="bi bi-check2-circle"></i>
<span><strong>Mais agilidade</strong> no dia a dia.</span>
</div>
<div class="value">
<i class="bi bi-check2-circle"></i>
<span><strong>Mais segurança</strong> na gestão.</span>
</div> </div>
</div> </div>
</section>
<div style="height: 80px;"></div>
<div class="row justify-content-center button-section" data-animate> </div>
<div class="col-auto"> </section>
<app-cta-button
label="COMEÇAR AGORA"
(clicked)="iniciar()">
</app-cta-button>
</div>
</div>
</div>
</section>
</section>

View File

@ -1,544 +1,451 @@
:host { :host {
--brand: #E33DCF;
--brand-soft: rgba(227, 61, 207, 0.14);
--brand-soft-2: rgba(227, 61, 207, 0.08);
--text: #111214;
--muted: rgba(17, 18, 20, 0.70);
--radius-xl: 22px;
--radius-lg: 16px;
display: block; display: block;
--brand-primary: #E33DCF;
--brand-soft: rgba(227, 61, 207, 0.15);
--brand-bg-light: #FDF4FC; /* Um rosa/branco muito sutil */
--text-main: #0F172A;
--text-muted: #64748B;
} }
/* ✅ FUNDO GLOBAL (vale para a Home TODA até o footer) */ .home-wrapper {
.home-page {
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
/* Não usamos overflow-hidden aqui para permitir o sticky footer funcionar nativamente */
}
/* ========================================= */
/* ✅ FUNDO GLOBAL FIXO (CORREÇÃO) */
/* ========================================= */
.home-global-bg {
position: fixed; /* Fixo na tela */
inset: 0; /* Cobre top, right, bottom, left */
z-index: -1; /* Fica atrás de tudo */
background-color: #F8FAFC; /* Cor base sólida */
overflow: hidden; overflow: hidden;
background:
radial-gradient(900px 420px at 20% 10%, var(--brand-soft), transparent 60%),
radial-gradient(820px 380px at 80% 30%, var(--brand-soft-2), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
/* névoa suave pra dar sensação de blur */
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 255, 255, 0.25);
}
} }
/* ✅ BLOBS FIXOS (continuam no scroll) */ /* O Mesh Overlay dá a textura geral */
.page-blob { .mesh-overlay {
position: fixed; position: absolute;
pointer-events: none; inset: 0;
border-radius: 999px; background:
filter: blur(34px); radial-gradient(circle at 15% 10%, var(--brand-soft) 0%, transparent 40%),
opacity: 0.55; radial-gradient(circle at 85% 30%, rgba(3, 15, 170, 0.08) 0%, transparent 40%),
z-index: 0; linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.8) 100%);
background: radial-gradient(circle at 30% 30%, rgba(227,61,207,0.55), rgba(227,61,207,0.06));
animation: floaty 10s ease-in-out infinite;
&.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; }
&.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; }
&.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; }
&.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: .45; }
@media (max-width: 992px) {
opacity: 0.35;
}
} }
/* ✅ garante que o conteúdo fique acima do fundo */ /* Blobs flutuantes e suaves */
.hero, .blob {
.features-section, position: absolute;
.container { border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
animation: floatBlob 10s ease-in-out infinite;
}
.blob-1 {
top: -100px;
left: -100px;
width: 600px;
height: 600px;
background: rgba(227, 61, 207, 0.12);
}
.blob-2 {
bottom: -150px;
right: -100px;
width: 500px;
height: 500px;
background: rgba(3, 15, 170, 0.08);
animation-delay: -5s;
}
@keyframes floatBlob {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20px, 30px); }
}
/* ========================================= */
/* CONTEÚDO SCROLLÁVEL */
/* ========================================= */
.content-scroll {
position: relative; position: relative;
z-index: 1; z-index: 1; /* Garante que o texto fique acima do fundo */
padding-top: 120px; /* Espaço para o Header Fixed */
} }
/* =============================== */ /* ========================================= */
/* HERO */ /* HERO SECTION */
/* =============================== */ /* ========================================= */
.hero { .hero-container {
padding: 56px 0 18px 0; margin-bottom: 60px;
} }
.hero-inner { .badge-saas {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 36px;
align-items: center;
@media (max-width: 992px) {
grid-template-columns: 1fr;
gap: 26px;
}
}
/* texto centralizado */
.hero-copy {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
/* badge */
.hero-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
background: white;
width: fit-content; border: 1px solid rgba(227, 61, 207, 0.2);
padding: 10px 14px; color: var(--brand-primary);
border-radius: 999px; padding: 6px 14px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.78); font-size: 12px;
border: 1px solid rgba(227, 61, 207, 0.22);
backdrop-filter: blur(10px);
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 700; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 24px;
box-shadow: 0 4px 10px rgba(227, 61, 207, 0.05);
i { color: var(--brand); font-size: 16px; } .pulse {
width: 8px;
height: 8px;
background: var(--brand-primary);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(227, 61, 207, 0.15);
}
} }
.hero-text-section { .display-title {
width: 100%;
margin-top: 12px;
}
/* título */
.main-title {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 52px; font-size: 3.5rem;
line-height: 1.05; font-weight: 800;
margin: 18px 0 18px 0; line-height: 1.15;
letter-spacing: -1.5px;
color: var(--text-main);
margin-bottom: 24px;
display: flex; @media(max-width: 992px) { font-size: 2.8rem; }
flex-direction: column; @media(max-width: 576px) { font-size: 2.2rem; }
align-items: center;
@media (max-width: 1400px) { font-size: 44px; } .text-gradient {
@media (max-width: 1024px) { font-size: 38px; } background: linear-gradient(90deg, var(--brand-primary) 0%, #030FAA 100%);
@media (max-width: 768px) { font-size: 32px; } -webkit-background-clip: text;
@media (max-width: 480px) { font-size: 28px; } -webkit-text-fill-color: transparent;
}
} }
.main-title .first-line, .lead-text {
.main-title .second-line { font-family: 'Inter', sans-serif;
font-weight: 650; font-size: 1.125rem;
display: block; color: var(--text-muted);
color: var(--text); line-height: 1.6;
text-align: center; max-width: 520px;
margin-bottom: 36px;
strong { color: var(--text-main); }
} }
.main-title strong { /* Botões */
color: var(--brand);
position: relative;
}
.main-title strong::after {
content: '';
position: absolute;
left: 0;
bottom: 6px;
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(227, 61, 207, 0.18);
z-index: -1;
}
/* parágrafo */
.main-paragraph {
width: min(980px, 100%);
margin: 0 auto 16px auto;
font-family: 'Poppins', sans-serif;
font-size: 20px;
color: var(--muted);
line-height: 1.45;
@media (max-width: 1400px) { font-size: 19px; }
@media (max-width: 1024px) { font-size: 18px; }
@media (max-width: 768px) { font-size: 16px; }
}
.main-paragraph .brand-name { color: var(--text); }
.main-paragraph .highlight { color: var(--text); font-weight: 800; }
.main-paragraph strong { font-weight: 800; }
/* botões */
.hero-actions { .hero-actions {
display: flex; display: flex;
gap: 14px; gap: 16px;
align-items: center; margin-bottom: 40px;
justify-content: center;
margin-top: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.cta-secondary { .btn-primary-saas {
height: 44px; background: var(--brand-primary);
padding: 0 14px; color: white;
border: none;
padding: 14px 32px;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.72); font-weight: 600;
border: 1px solid rgba(17, 18, 20, 0.10); font-size: 16px;
color: var(--text); cursor: pointer;
font-weight: 800; transition: all 0.2s;
display: inline-flex; box-shadow: 0 10px 25px -5px rgba(227, 61, 207, 0.3);
align-items: center;
gap: 10px;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
i { color: var(--brand); }
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
border-color: rgba(227, 61, 207, 0.28); background: #d42bbf;
box-shadow: 0 12px 24px rgba(17, 18, 20, 0.10); box-shadow: 0 15px 30px -5px rgba(227, 61, 207, 0.4);
} }
} }
/* =============================== */ .btn-secondary-saas {
/* MOCK (direita) */ background: white;
/* =============================== */ color: var(--text-main);
.hero-mock { border: 1px solid rgba(0,0,0,0.1);
display: flex; padding: 14px 24px;
justify-content: flex-end; border-radius: 12px;
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
@media (max-width: 992px) { &:hover {
background: #f8f9fa;
border-color: rgba(0,0,0,0.2);
transform: translateY(-2px);
}
}
.trust-strip {
display: flex;
gap: 24px;
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
span {
display: flex;
align-items: center;
gap: 6px;
}
i { color: var(--brand-primary); font-size: 16px; }
}
/* ========================================= */
/* MOCKUP 3D (DIREITA) */
/* ========================================= */
.mockup-perspective {
position: relative;
perspective: 1000px;
padding: 20px;
@media(max-width: 992px) {
margin-top: 40px;
display: flex;
justify-content: center; justify-content: center;
} }
} }
.mock-card { .mockup-card {
width: min(460px, 100%); background: rgba(255, 255, 255, 0.75);
border-radius: var(--radius-xl); backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.78); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(227, 61, 207, 0.14); border: 1px solid rgba(255,255,255,0.8);
backdrop-filter: blur(12px); border-radius: 24px;
box-shadow: 0 22px 46px rgba(17, 18, 20, 0.10); box-shadow:
overflow: hidden; 0 25px 50px -12px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255,255,255,0.5) inset;
transform: perspective(900px) rotateY(-6deg) rotateX(2deg);
transition: transform 200ms ease; transform: rotateY(-10deg) rotateX(4deg);
transition: transform 0.5s ease;
width: 100%;
max-width: 480px;
min-height: 340px;
padding: 24px;
&:hover { &:hover {
transform: perspective(900px) rotateY(-2deg) rotateX(1deg) translateY(-2px); transform: rotateY(-4deg) rotateX(2deg) translateY(-10px);
} }
} }
.mock-top { /* Detalhes internos do Mock */
.mock-header {
display: flex;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0,0,0,0.05);
.dots span {
display: inline-block;
width: 10px; height: 10px;
background: #E2E8F0; border-radius: 50%;
margin-right: 6px;
}
.bar {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.kpi-row {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.kpi-box {
flex: 1;
background: #fff;
padding: 12px 16px;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.02);
border: 1px solid rgba(0,0,0,0.03);
small {
display: block;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 4px;
}
strong {
font-size: 22px;
color: var(--text-main);
font-family: 'Inter', sans-serif;
}
}
.fake-chart {
display: flex;
align-items: flex-end;
gap: 12px;
height: 100px;
margin-bottom: 24px;
padding: 0 10px;
.bar {
flex: 1;
background: #F1F5F9;
border-radius: 6px;
transition: height 1s ease;
}
.bar.active {
background: linear-gradient(180deg, var(--brand-primary) 0%, #B832A8 100%);
box-shadow: 0 4px 10px rgba(227, 61, 207, 0.2);
}
}
.fake-list .item {
height: 36px;
background: white;
margin-bottom: 10px;
border-radius: 8px;
display: flex;
align-items: center;
padding: 0 12px;
border: 1px solid rgba(0,0,0,0.02);
span {
width: 20px;
height: 20px;
background: #F1F5F9;
border-radius: 50%;
margin-right: 12px;
}
.line {
flex: 1;
height: 6px;
background: #F1F5F9;
border-radius: 4px;
}
}
.floating-badge {
position: absolute;
bottom: 40px;
left: -30px;
background: white;
padding: 12px 24px;
border-radius: 50px;
box-shadow: 0 15px 35px rgba(0,0,0,0.08);
font-weight: 700;
font-size: 14px;
color: var(--text-main);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 14px 16px; animation: floatBadge 5s ease-in-out infinite;
background: linear-gradient(180deg, rgba(227, 61, 207, 0.10), rgba(255, 255, 255, 0.20)); z-index: 10;
.dot { @media(max-width: 576px) { left: 0; bottom: -20px; }
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(17, 18, 20, 0.12);
}
.mock-title { i { color: #10B981; font-size: 18px; }
margin-left: 6px;
font-weight: 950;
font-family: 'Inter', sans-serif;
color: var(--text);
}
} }
.mock-grid { @keyframes floatBadge {
padding: 16px; 0%, 100% { transform: translateY(0); }
display: grid; 50% { transform: translateY(-8px); }
grid-template-columns: 1fr 1fr;
gap: 12px;
@media (max-width: 380px) { grid-template-columns: 1fr; }
} }
.mock-kpi { /* ========================================= */
border-radius: var(--radius-lg); /* FEATURES SECTION */
background: #fff; /* ========================================= */
border: 1px solid rgba(17, 18, 20, 0.08);
padding: 12px;
.kpi-label {
display: block;
font-size: 12px;
color: rgba(17, 18, 20, 0.65);
font-family: 'Inter', sans-serif;
font-weight: 800;
}
.kpi-value {
display: block;
font-size: 26px;
font-family: 'Inter', sans-serif;
font-weight: 950;
color: var(--text);
margin-top: 2px;
}
.kpi-tag {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(227, 61, 207, 0.20);
background: rgba(227, 61, 207, 0.07);
color: var(--text);
font-size: 12px;
font-weight: 900;
i { color: var(--brand); }
}
}
.mock-line {
grid-column: 1 / -1;
border-radius: var(--radius-lg);
background: #fff;
border: 1px solid rgba(17, 18, 20, 0.08);
padding: 12px;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
.line-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(227, 61, 207, 0.10);
display: grid;
place-items: center;
i { color: var(--brand); font-size: 18px; }
}
.line-title {
font-weight: 950;
font-family: 'Inter', sans-serif;
color: var(--text);
font-size: 14px;
}
.line-sub {
color: rgba(17, 18, 20, 0.65);
font-size: 12px;
margin-top: 2px;
font-family: 'Inter', sans-serif;
}
.line-pill {
padding: 8px 10px;
border-radius: 999px;
font-weight: 950;
font-size: 12px;
background: rgba(227, 61, 207, 0.10);
border: 1px solid rgba(227, 61, 207, 0.22);
color: var(--text);
}
}
/* =============================== */
/* MÉTRICAS CENTRALIZADAS (MEIO) */
/* =============================== */
.hero-metrics-wide {
margin-top: 18px;
display: flex;
justify-content: center;
}
.hero-metrics {
display: flex;
gap: 14px;
flex-wrap: wrap;
justify-content: center;
}
.metric {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(227, 61, 207, 0.16);
backdrop-filter: blur(10px);
i { color: var(--brand); font-size: 18px; }
.metric-title {
display: block;
font-weight: 900;
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 14px;
line-height: 1.1;
}
.metric-sub {
display: block;
font-family: 'Inter', sans-serif;
font-size: 12px;
color: rgba(17, 18, 20, 0.65);
}
}
/* =============================== */
/* FEATURES */
/* =============================== */
.features-section { .features-section {
padding: 18px 0 60px 0; padding: 80px 0;
background: transparent; position: relative;
}
.section-head {
text-align: center;
margin-bottom: 24px;
} }
.section-title { .section-title {
font-size: 32px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 10px;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-weight: 950;
color: var(--text);
font-size: 30px;
@media (max-width: 768px) { font-size: 24px; }
} }
.section-title .brand { color: var(--brand); }
.section-subtitle { .section-subtitle {
margin-top: 10px; font-size: 16px;
color: var(--muted); color: var(--text-muted);
font-family: 'Poppins', sans-serif; max-width: 600px;
margin: 0 auto;
} }
/* ✅ AQUI: 3 CARDS CENTRALIZADOS LADO A LADO NO NOTEBOOK */ .grid-features {
.feature-cards-row { display: grid;
display: flex; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
justify-content: center; gap: 32px;
align-items: stretch;
gap: 26px;
flex-wrap: nowrap; /* ✅ força ficar em 1 linha */
/* notebook / telas médias */
@media (max-width: 1199.98px) {
gap: 18px;
}
/* quando a tela ficar pequena de verdade, aí quebra */
@media (max-width: 992px) {
flex-wrap: wrap;
}
}
/* Garante que o col-auto do bootstrap não atrapalhe */
.feature-item {
display: flex;
justify-content: center;
align-items: stretch;
margin-bottom: 0 !important;
}
/* melhora o respiro no botão */
.button-section {
margin-top: 18px;
}
/* faixa de valores */
.value-strip {
margin-top: 50px; margin-top: 50px;
padding: 14px 16px; }
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.80);
border: 1px solid rgba(227, 61, 207, 0.14);
backdrop-filter: blur(10px);
display: flex; .feature-box {
justify-content: center; background: rgba(255, 255, 255, 0.6);
gap: 18px; backdrop-filter: blur(10px); /* Glassmorphism nos cards também */
flex-wrap: wrap; padding: 32px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
.value { &:hover {
display: inline-flex; transform: translateY(-8px);
align-items: center; background: white;
gap: 10px; box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.08);
border-color: rgba(227, 61, 207, 0.1);
i { color: var(--brand); font-size: 18px; } .icon-sq {
span { color: var(--text); font-family: 'Inter', sans-serif; } background: var(--brand-primary);
color: white;
transform: scale(1.1) rotate(-3deg);
}
}
.icon-sq {
width: 54px;
height: 54px;
background: rgba(227, 61, 207, 0.08);
color: var(--brand-primary);
border-radius: 14px;
display: grid;
place-items: center;
font-size: 22px;
margin-bottom: 22px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
h3 {
font-size: 20px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-main);
}
p {
font-size: 15px;
color: var(--text-muted);
line-height: 1.6;
} }
} }
/* =============================== */ /* Utilitário de Animação */
/* ✅ ANIMAÇÕES SSR-SAFE */ .fade-in-up {
/* =============================== */ animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
[data-animate] { opacity: 1; transform: none; }
.js-animate [data-animate] {
opacity: 0; opacity: 0;
transform: translateY(14px); transform: translateY(30px);
transition: opacity 600ms ease, transform 600ms ease;
will-change: opacity, transform;
} }
.js-animate [data-animate].is-visible { @keyframes fadeInUp {
opacity: 1; to { opacity: 1; transform: translateY(0); }
transform: translateY(0); }
}
/* brilho suave no botão */
:host ::ng-deep app-cta-button .btn,
:host ::ng-deep app-cta-button button {
position: relative;
overflow: hidden;
transform: translateZ(0);
}
:host ::ng-deep app-cta-button .btn::after,
:host ::ng-deep app-cta-button button::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-120%) rotate(12deg);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);
animation: shine 3.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.page-blob { animation: none; }
.js-animate [data-animate] { transition: none; transform: none; opacity: 1; }
:host ::ng-deep app-cta-button .btn::after,
:host ::ng-deep app-cta-button button::after { animation: none; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
@keyframes shine {
0%, 65% { transform: translateX(-120%) rotate(12deg); opacity: 0.0; }
75% { opacity: 1; }
100% { transform: translateX(120%) rotate(12deg); opacity: 0.0; }
}

View File

@ -30,6 +30,10 @@ export class Home implements AfterViewInit {
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById('features')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
goToLogin(): void {
this.router.navigate(['/login']);
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!this.isBrowser) return; if (!this.isBrowser) return;
@ -64,5 +68,6 @@ export class Home implements AfterViewInit {
items.forEach(el => io.observe(el)); items.forEach(el => io.observe(el));
}, 0); }, 0);
} }
} }

View File

@ -1,73 +1,126 @@
<div class="login-wrapper"> <div class="login-wrapper">
<div class="login-card shadow-sm">
<div class="login-left">
<!-- Título -->
<div class="login-title mb-4"> <div class="left-content fade-in-up">
<h2 class="mb-0">Login</h2> <div class="brand-header mb-4">
</div> <div class="brand-logo">
<i class="bi bi-layers-fill"></i>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()"> <span>LineGestão</span>
<!-- Usuário -->
<div class="mb-3">
<label class="form-label">Usuário</label>
<input
type="text"
class="form-control"
formControlName="username"
placeholder="Usuário" />
<div class="text-danger small mt-1" *ngIf="hasError('username')">
Informe o usuário.
</div> </div>
</div> </div>
<!-- Senha --> <h1 class="welcome-title">Acesse sua conta</h1>
<div class="mb-4"> <p class="welcome-subtitle">Informe suas credenciais para entrar na plataforma.</p>
<label class="form-label">Senha</label>
<input
type="password"
class="form-control"
formControlName="password"
placeholder="Senha" />
<div class="text-danger small mt-1" *ngIf="hasError('password')"> <form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
A senha deve ter pelo menos 6 caracteres.
<div class="form-group">
<label for="email">E-mail</label>
<input
type="email"
id="email"
formControlName="username"
placeholder="admin@empresa.com"
[class.error]="hasError('username')"
>
<div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div>
</div>
<div class="form-group">
<label for="password">Senha</label>
<div class="input-wrapper">
<input
[type]="showPassword ? 'text' : 'password'"
id="password"
formControlName="password"
placeholder="••••••••••••"
[class.error]="hasError('password')"
>
<button type="button" class="toggle-pass" (click)="togglePassword()">
<i class="bi" [ngClass]="showPassword ? 'bi-eye-slash' : 'bi-eye'"></i>
</button>
</div>
<div class="error-msg" *ngIf="hasError('password')">Senha inválida.</div>
</div>
<div class="form-actions">
<label class="custom-checkbox">
<input type="checkbox" formControlName="rememberMe">
<span class="checkmark"></span>
Lembrar de mim
</label>
<a href="#" class="forgot-link">Esqueceu a senha?</a>
</div>
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">
<span *ngIf="!isSubmitting">Entrar</span>
<div *ngIf="isSubmitting" class="spinner"></div>
</button>
<div *ngIf="apiError" class="api-alert">
<i class="bi bi-exclamation-circle"></i> {{ apiError }}
</div>
</form>
<div class="left-footer mt-5">
<span>&copy; 2026 Ingline Systems.</span>
<a href="#">Privacidade</a>
</div>
</div>
</div>
<div class="login-right">
<div class="right-content">
<div class="text-area">
<h2 class="hero-title">Gerencie suas linhas com<br>inteligência e controle.</h2>
<p class="hero-desc">Acesse o dashboard completo para monitorar contratos, faturas e usuários.</p>
</div>
<div class="dashboard-mockup fade-in-up" style="animation-delay: 0.2s;">
<div class="mock-top-bar">
<div class="dots"><span></span><span></span></div>
<div class="mock-search"></div>
<div class="mock-profile"></div>
</div>
<div class="mock-grid">
<div class="mock-card highlight">
<span>Gasto Mensal</span>
<h3>R$ 12.450</h3>
<div class="badge-mock"><i class="bi bi-arrow-down"></i> -2.4%</div>
</div>
<div class="mock-card chart-card">
<span>Linhas Ativas</span>
<div class="fake-bars">
<div class="bar" style="height: 40%"></div>
<div class="bar active" style="height: 80%"></div>
<div class="bar" style="height: 60%"></div>
<div class="bar" style="height: 50%"></div>
<div class="bar" style="height: 70%"></div>
</div>
</div>
<div class="mock-card table-card">
<div class="table-row head"></div>
<div class="table-row"><div class="avatar"></div><div class="line"></div><div class="status ok"></div></div>
<div class="table-row"><div class="avatar"></div><div class="line"></div><div class="status warn"></div></div>
<div class="table-row"><div class="avatar"></div><div class="line"></div><div class="status ok"></div></div>
</div>
</div>
<div class="float-card">
<div class="icon-circle"><i class="bi bi-sim"></i></div>
<div>
<strong>128 Linhas</strong>
<small>Monitoradas</small>
</div>
</div> </div>
</div> </div>
<!-- Erro da API -->
<div *ngIf="apiError" class="alert alert-danger py-2 mb-3">
{{ apiError }}
</div>
<!-- Botão Entrar -->
<button
type="submit"
class="btn btn-primary w-100 login-btn-submit"
[disabled]="isSubmitting">
{{ isSubmitting ? 'Entrando...' : 'ENTRAR' }}
</button>
</form>
</div>
</div>
<!-- Toast (Sucesso Login) -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000;">
<div
#successToast
class="toast text-bg-success"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="toast-header">
<strong class="me-auto">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body">
{{ toastMessage }}
</div> </div>
</div> </div>
</div>
</div>

View File

@ -1,216 +1,530 @@
/* ========================= */ :host {
/* TELA DE LOGIN */ /* Paleta de Cores LineGestão */
/* ========================= */ --brand-blue: #030FAA; /* Azul Principal */
--brand-blue-dark: #020865; /* Azul Profundo (Gradiente) */
--brand-pink: #E33DCF; /* Rosa (Detalhes/Acentos) */
--text-main: #1e293b; /* Cinza Escuro (Texto) */
--text-muted: #64748b; /* Cinza Médio (Legendas) */
--bg-input: #f8fafc; /* Fundo dos inputs */
--border-input: #e2e8f0; /* Borda dos inputs */
}
/* Wrapper para centralizar o card entre header e footer */ /* ================================================= */
/* CONTAINER GLOBAL (TELA DIVIDIDA) */
/* ================================================= */
.login-wrapper { .login-wrapper {
min-height: calc(100vh - 69.2px);
display: flex; display: flex;
justify-content: center; /* login fica no centro */
align-items: center;
padding-top: 32px;
padding-right: 12px;
padding-bottom: 100px; /* mesmo “respiro” do cadastro */
padding-left: 12px;
background-color: #efefef;
/* IMAGEM DESKTOP (PADRÃO) */
background-image: url('../../../assets/wallpaper/registro_login.png');
background-repeat: no-repeat;
background-position: right top;
background-size: cover;
/* 🔑 fundo preso ao viewport (igual cadastro) */
background-attachment: fixed;
}
/* NOTEBOOKS / TABLETS */
@media (max-width: 992px) {
.login-wrapper {
padding-top: 24px;
padding-left: 16px;
padding-right: 16px;
padding-bottom: 60px;
background-position: center top;
background-size: cover;
background-attachment: scroll;
}
}
/* CELULARES IMAGEM MOBILE */
@media (max-width: 576px) {
.login-wrapper {
min-height: calc(100vh - 40px);
padding-top: 20px;
padding-bottom: 32px;
padding-left: 16px;
padding-right: 16px;
background-image: url('../../../assets/wallpaper/mobile.png');
background-repeat: no-repeat;
background-position: center top;
background-size: cover;
background-attachment: scroll;
}
}
/* ========================= */
/* CARD DE LOGIN */
/* ========================= */
/* Desktop grande (monitor) */
.login-card {
background-color: transparent;
border-radius: 10px;
border: 2px solid #c91eb5;
max-width: 480px; /* antes 500px */
width: 100%; width: 100%;
min-height: 360px; /* antes 380px */ height: 100vh; /* Ocupa 100% da altura da viewport */
padding: 26px 22px; /* antes 28px 24px */ min-height: 600px; /* Evita esmagamento em telas muito baixas */
box-sizing: border-box; background: #fff;
font-family: 'Inter', sans-serif;
overflow: hidden; /* Garante que nada gere scroll indesejado na tela principal */
backdrop-filter: blur(6px); /* Responsivo: Vira coluna no mobile/tablet vertical */
-webkit-backdrop-filter: blur(6px); @media (max-width: 992px) {
flex-direction: column;
.mb-3, height: auto;
.mb-4 { min-height: 100vh;
margin-bottom: 0.8rem; /* antes 0.9rem */ overflow-y: auto; /* Permite scroll no mobile */
} }
} }
/* NOTEB00KS (≤1440px) deixa ainda mais compacto */ /* ================================================= */
@media (max-width: 1440px) { /* LADO ESQUERDO (FORMULÁRIO) */
.login-card { /* ================================================= */
max-width: 430px; .login-left {
min-height: 330px; flex: 1; /* Ocupa o espaço necessário */
padding: 22px 20px;
}
.login-title h2 {
font-size: 30px; /* levemente menor */
}
}
/* notebooks / tablets (≤992px) */
@media (max-width: 992px) {
.login-card {
max-width: 400px;
min-height: 310px;
padding: 20px 18px;
}
.login-title h2 {
font-size: 26px;
}
.form-control {
height: 36px;
font-size: 13px;
}
.login-btn-submit {
font-size: 13px;
padding: 7px 0;
}
.mb-3,
.mb-4 {
margin-bottom: 0.7rem;
}
}
/* celulares (≤576px) bem enxuto */
@media (max-width: 576px) {
.login-card {
max-width: 340px;
min-height: auto;
padding: 18px 14px;
}
.login-title h2 {
font-size: 24px;
}
.form-control {
height: 34px;
font-size: 13px;
}
.login-btn-submit {
font-size: 12.5px;
padding: 7px 0;
}
}
/* ========================= */
/* TIPOGRAFIA E FORM */
/* ========================= */
/* Título centralizado rosa */
.login-title {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; justify-content: center; /* Centraliza verticalmente */
margin-bottom: 1.25rem !important; padding: 0 60px;
background: white;
position: relative;
z-index: 5;
h2 { /* Ajuste Notebook: Menos padding lateral */
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; @media (max-width: 1366px) { padding: 0 40px; }
font-weight: 700;
font-size: 32px; /* Ajuste Mobile */
color: #c91eb5; @media (max-width: 576px) { padding: 32px 24px; }
margin: 0; }
/* Conteúdo Centralizado (Logo + Form) */
.left-content {
max-width: 380px;
width: 100%;
margin: 0 auto;
}
/* Logo no topo do form */
.brand-header {
margin-bottom: 32px;
.brand-logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 700;
color: var(--text-main);
i { color: var(--brand-blue); font-size: 24px; }
} }
} }
/* Labels mesmo tamanho do cadastro */ /* Títulos */
.form-label { .welcome-title {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 32px;
font-weight: 500; font-weight: 700;
font-size: 14px; color: var(--text-main);
color: #000000; margin-bottom: 8px;
letter-spacing: -0.5px;
line-height: 1.2;
@media (max-width: 1366px) { font-size: 28px; }
} }
/* Inputs iguais aos do cadastro (borda azul) */ .welcome-subtitle {
.form-control { color: var(--text-muted);
height: 38px; margin-bottom: 32px;
font-size: 15px;
}
/* INPUTS E LABELS */
.form-group {
margin-bottom: 18px;
label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-main);
margin-bottom: 6px;
}
input {
width: 100%;
height: 48px; /* Altura confortável */
padding: 0 16px;
border-radius: 10px;
border: 1px solid var(--border-input);
background: var(--bg-input);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: all 0.2s;
/* Ajuste Notebook: levemente menor */
@media (max-width: 1366px) { height: 44px; }
&::placeholder { color: #cbd5e1; }
&:focus {
background: #fff;
border-color: var(--brand-blue);
box-shadow: 0 0 0 3px rgba(3, 15, 170, 0.1);
}
&.error {
border-color: #ef4444;
background: #fef2f2;
}
}
}
/* Wrapper para input de senha com ícone */
.input-wrapper {
position: relative;
input { padding-right: 40px; }
.toggle-pass {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 16px;
&:hover { color: var(--text-main); }
}
}
.error-msg {
color: #ef4444;
font-size: 11px;
margin-top: 4px;
font-weight: 500;
}
/* AÇÕES (Checkbox + Esqueci Senha) */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
font-size: 13px;
}
.custom-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-muted);
user-select: none;
input { display: none; }
.checkmark {
width: 16px;
height: 16px;
border: 1px solid var(--border-input);
border-radius: 4px;
position: relative;
transition: all 0.2s;
}
input:checked ~ .checkmark {
background: var(--brand-blue);
border-color: var(--brand-blue);
&::after {
content: '';
position: absolute;
left: 4px; top: 1px;
width: 5px; height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
.forgot-link {
color: var(--brand-blue);
text-decoration: none;
font-weight: 600;
font-size: 13px;
&:hover { text-decoration: underline; }
}
/* BOTÃO PRINCIPAL */
.btn-primary-login {
width: 100%;
height: 48px;
background: var(--brand-blue);
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--brand-blue-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(3, 15, 170, 0.25);
}
&:disabled {
background: #94a3b8;
cursor: not-allowed;
}
}
/* FOOTER INTERNO */
.left-footer {
margin-top: 40px;
font-size: 11px;
color: var(--text-muted);
display: flex;
justify-content: space-between;
a {
color: var(--text-main);
text-decoration: none;
&:hover { text-decoration: underline; }
}
}
/* Alerta de erro da API */
.api-alert {
margin-top: 16px;
padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
border: 2px solid #6066ff; background: #fef2f2;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #ef4444;
font-weight: 500; font-size: 12px;
font-size: 14px; display: flex;
color: #000000; align-items: center;
gap: 8px;
border: 1px solid #fecaca;
}
&::placeholder { /* ================================================= */
color: rgba(0, 0, 0, 0.5); /* LADO DIREITO (VISUAL / MOCKUP) */
/* ================================================= */
.login-right {
flex: 1.3; /* Ligeiramente maior que o form */
padding: 16px; /* Borda branca ao redor do bloco azul */
background: #fff;
display: flex;
overflow: hidden;
/* Some no tablet/mobile */
@media (max-width: 992px) { display: none; }
}
.right-content {
width: 100%;
height: 100%;
border-radius: 24px;
position: relative;
overflow: hidden; /* Corta o mockup que sair da área */
/* Gradiente Azul LineGestão */
background: radial-gradient(circle at 20% 20%, #2a39ff 0%, var(--brand-blue) 60%, var(--brand-blue-dark) 100%);
display: flex;
flex-direction: column;
padding: 56px;
color: white;
/* Ajuste Notebook: Menos padding */
@media (max-width: 1366px) { padding: 40px; }
}
.text-area {
position: relative;
z-index: 2;
margin-bottom: 20px;
}
.hero-title {
font-size: 36px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 16px;
@media (max-width: 1366px) { font-size: 28px; }
}
.hero-desc {
font-size: 16px;
opacity: 0.85;
max-width: 450px;
line-height: 1.5;
@media (max-width: 1366px) { font-size: 14px; }
}
/* ================================================= */
/* MOCKUP SISTEMA (CSS PURO) */
/* ================================================= */
.dashboard-mockup {
position: absolute;
/* Posicionamento estratégico para não cortar feio */
bottom: -30px;
right: -30px;
width: 85%; /* Largura relativa */
max-width: 700px; /* Trava máxima */
aspect-ratio: 16/10; /* Mantém proporção de tela wide */
background: #F1F5F9; /* Cinza claro (fundo do sistema) */
border-top-left-radius: 24px;
box-shadow: -20px -20px 60px rgba(0,0,0,0.35); /* Sombra forte para dar profundidade */
display: flex;
flex-direction: column;
padding: 24px;
z-index: 1;
/* 🔥 CORREÇÃO CRÍTICA PARA NOTEBOOKS 🔥 */
/* Se a tela for baixa ou estreita, diminui o mockup automaticamente */
@media (max-height: 800px), (max-width: 1366px) {
width: 80%;
right: -20px;
bottom: -20px;
padding: 16px;
} }
} }
/* Botão ENTRAR rosa sólido, mesmo estilo do cadastrar */ /* Barra do Topo (Mock) */
.login-btn-submit { .mock-top-bar {
border-radius: 40px; display: flex;
border: none; align-items: center;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; gap: 12px;
font-weight: 600; margin-bottom: 20px;
font-size: 14px;
letter-spacing: 0.5px; .dots span {
text-transform: uppercase; display: inline-block;
padding: 9px 0; width: 8px; height: 8px;
background-color: #c91eb5; border-radius: 50%;
color: #ffffff; background: #cbd5e1;
margin-right: 6px;
&:hover { }
filter: brightness(1.04); .mock-search {
flex: 1; height: 32px;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.mock-profile {
width: 32px; height: 32px;
background: #cbd5e1;
border-radius: 50%;
} }
} }
/* Mensagens de erro */ /* Grid Interno (Mock) */
.text-danger.small { .mock-grid {
font-size: 11px; display: grid;
grid-template-columns: 1.2fr 1fr; /* Coluna esquerda levemente maior */
grid-template-rows: auto 1fr;
gap: 16px;
height: 100%;
} }
/* Cards Genéricos (Mock) */
.mock-card {
background: white;
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
box-shadow: 0 2px 6px rgba(0,0,0,0.03);
/* Card Destaque (Azul) */
&.highlight {
background: var(--brand-blue);
color: white;
span { font-size: 11px; opacity: 0.8; margin-bottom: 4px; display: block; }
h3 { font-size: 24px; margin: 0 0 8px 0; font-weight: 700; }
.badge-mock {
font-size: 10px;
background: rgba(255,255,255,0.2);
padding: 4px 10px;
border-radius: 20px;
width: fit-content;
display: flex; align-items: center; gap: 4px;
}
}
/* Card Gráfico */
&.chart-card {
grid-row: span 2; /* Ocupa altura toda */
span { font-size: 12px; font-weight: 600; color: #1e293b; margin-bottom: 16px; display: block;}
.fake-bars {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 70%;
padding: 0 8px;
.bar {
width: 12px;
background: #e2e8f0;
border-radius: 10px;
/* A barra rosa ativa */
&.active {
background: var(--brand-pink);
box-shadow: 0 4px 10px rgba(227, 61, 207, 0.3);
}
}
}
}
/* Card Tabela */
&.table-card {
gap: 10px;
padding-top: 12px;
.table-row {
display: flex; align-items: center; gap: 10px; height: 28px;
&.head { background: #f1f5f9; border-radius: 6px; height: 24px; width: 100%; margin-bottom: 4px; }
.avatar { width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; }
.line { flex: 1; height: 8px; background: #f1f5f9; border-radius: 4px; }
.status { width: 8px; height: 8px; border-radius: 50%; }
.status.ok { background: #10b981; }
.status.warn { background: #f59e0b; }
}
}
}
/* Card Flutuante (Badges) */
.float-card {
position: absolute;
top: 35%;
left: -40px;
background: white;
padding: 12px 20px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.12); /* Sombra suave */
display: flex;
align-items: center;
gap: 12px;
z-index: 10;
animation: float 5s ease-in-out infinite;
/* Ajuste no notebook */
@media (max-width: 1366px) { left: -24px; padding: 10px 16px; }
.icon-circle {
width: 40px; height: 40px;
background: rgba(227, 61, 207, 0.1); /* Rosa suave */
color: var(--brand-pink);
border-radius: 10px;
display: grid; place-items: center;
font-size: 18px;
@media (max-width: 1366px) { width: 36px; height: 36px; font-size: 16px; }
}
strong { display: block; font-size: 14px; color: #1e293b; font-weight: 700; }
small { font-size: 11px; color: #64748b; }
}
/* Animações Utilitárias */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
to { opacity: 1; transform: translateY(0); }
}
.spinner {
width: 20px; height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
transform: translateY(20px);
}

View File

@ -16,6 +16,9 @@ export class LoginComponent {
isSubmitting = false; isSubmitting = false;
apiError = ''; apiError = '';
// Variável necessária para o ícone de olho no HTML novo
showPassword = false;
toastMessage = ''; toastMessage = '';
@ViewChild('successToast') successToast!: ElementRef; @ViewChild('successToast') successToast!: ElementRef;
@ -26,11 +29,18 @@ export class LoginComponent {
@Inject(PLATFORM_ID) private platformId: object @Inject(PLATFORM_ID) private platformId: object
) { ) {
this.loginForm = this.fb.group({ this.loginForm = this.fb.group({
username: ['', [Validators.required]], // aqui é email username: ['', [Validators.required]],
password: ['', [Validators.required, Validators.minLength(6)]] password: ['', [Validators.required, Validators.minLength(6)]],
// Adicionado apenas para não dar erro no HTML novo, mas a lógica de envio ignora se quiser
rememberMe: [false]
}); });
} }
// Método necessário para o botão do "olhinho" funcionar
togglePassword(): void {
this.showPassword = !this.showPassword;
}
private async showToast(message: string) { private async showToast(message: string) {
this.toastMessage = message; this.toastMessage = message;
@ -65,49 +75,66 @@ export class LoginComponent {
// evita token antigo conflitar // evita token antigo conflitar
localStorage.removeItem('token'); localStorage.removeItem('token');
// Se quiser implementar a lógica de "Manter conectado", pode verificar o rememberMe aqui
// mas mantive a lógica original simples:
localStorage.setItem('token', token); localStorage.setItem('token', token);
} }
onSubmit(): void { onSubmit(): void {
console.log('🚀 Iniciando login...');
this.apiError = ''; this.apiError = '';
if (this.loginForm.invalid) { if (this.loginForm.invalid) {
console.log('❌ Formulário inválido', this.loginForm.errors);
this.loginForm.markAllAsTouched(); this.loginForm.markAllAsTouched();
return; return;
} }
this.isSubmitting = true; this.isSubmitting = true;
const v = this.loginForm.value; const v = this.loginForm.value;
const payload = { this.authService.login({ email: v.username, password: v.password }).subscribe({
email: v.username, next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
password: v.password console.log('✅ Resposta da API:', res);
};
this.authService.login(payload).subscribe({
next: async (res) => {
this.isSubmitting = false; this.isSubmitting = false;
const token = res?.token; // VERIFICAÇÃO 1: O token veio mesmo?
// As vezes vem como res.accessToken, res.data.token, etc.
const token = res?.token || res?.accessToken;
if (!token) { if (!token) {
this.apiError = 'Login retornou sem token. Verifique a resposta da API.'; console.error('❌ Token não encontrado na resposta!');
this.apiError = 'Erro: A API não retornou o token.';
return; return;
} }
// ✅ salva token para o Interceptor anexar nas próximas requisições console.log('🔑 Token encontrado. Salvando...');
this.saveToken(token); this.saveToken(token);
const nome = this.getNameFromToken(token); // VERIFICAÇÃO 2: Decodificação
try {
const nome = this.getNameFromToken(token);
console.log('👤 Nome extraído:', nome);
console.log('🔄 Tentando ir para /geral...');
this.router.navigate(['/geral'], {
state: { toastMessage: `Bem-vindo, ${nome}!` }
}).then(sucesso => {
if (sucesso) console.log('✅ Navegação funcionou!');
else console.error('❌ Navegação falhou! A rota "/geral" existe?');
});
// ✅ Vai para /geral já levando a mensagem do toast } catch (e) {
this.router.navigate(['/geral'], { console.error('❌ Erro ao processar token ou navegar:', e);
state: { toastMessage: `Bem-vindo, ${nome}!` } // Força a ida mesmo se o nome falhar
}); this.router.navigate(['/geral']);
}
}, },
error: (err) => { error: (err) => {
console.error('❌ Erro na requisição:', err);
this.isSubmitting = false; this.isSubmitting = false;
this.apiError = err?.error ?? 'Erro ao fazer login.'; this.apiError = err?.error?.message || 'Usuário ou senha incorretos.';
} }
}); });
} }
@ -116,6 +143,6 @@ export class LoginComponent {
const control = this.loginForm.get(field); const control = this.loginForm.get(field);
if (!control) return false; if (!control) return false;
if (error) return control.touched && control.hasError(error); if (error) return control.touched && control.hasError(error);
return control.touched && control.invalid; return !!(control.touched && control.invalid);
} }
} }

View File

@ -18,19 +18,21 @@
<div class="container-mureg"> <div class="container-mureg">
<div class="mureg-card" data-animate> <div class="mureg-card" data-animate>
<div class="mureg-header"> <div class="mureg-header">
<div class="header-row-top"> <div class="header-row-top">
<div class="title-badge" data-animate> <div class="title-badge" data-animate>
<i class="bi bi-table"></i> MUREG <i class="bi bi-table"></i> MUREG
</div> </div>
<div class="header-title" data-animate> <div class="header-title" data-animate>
<h5 class="title mb-0">MUREG</h5> <h5 class="title mb-0">MUREG</h5>
<small class="subtitle">Gestão de registros MUREG</small> <small class="subtitle">Gestão de registros MUREG</small>
</div> </div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate> <div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading"> <button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg <i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button> </button>
</div> </div>
</div> </div>
@ -39,70 +41,91 @@
<div class="kpi"> <div class="kpi">
<span class="lbl">Clientes</span> <span class="lbl">Clientes</span>
<span class="val"> <span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span> <span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ total || 0 }}</span> <span *ngIf="!loading">{{ total || 0 }}</span>
</span> </span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl">Registros</span> <span class="lbl">Registros</span>
<span class="val"> <span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span> <span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span> <span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span>
</span> </span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl text-brand">Trocas</span> <span class="lbl text-brand">Trocas</span>
<span class="val text-brand"> <span class="val text-brand">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span> <span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span> <span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span>
</span> </span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl text-success">ICCID</span> <span class="lbl text-success">ICCID</span>
<span class="val text-success"> <span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span> <span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span> <span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span>
</span> </span>
</div> </div>
</div> </div>
<div class="controls mt-3 mb-2" data-animate> <div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group"> <div class="input-group input-group-sm search-group">
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span> <span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
</span>
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" /> <input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm"><i class="bi bi-x-lg"></i></button> <button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i>
</button>
</div> </div>
<div class="page-size d-flex align-items-center gap-2"> <div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span> <span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
</span>
<div class="select-wrapper"> <div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading"> <select
<option [ngValue]="10">10</option> class="form-select form-select-sm select-glass"
<option [ngValue]="20">20</option> [(ngModel)]="pageSize"
<option [ngValue]="50">50</option> (change)="onPageSizeChange()"
<option [ngValue]="100">100</option> [disabled]="loading"
</select> >
<i class="bi bi-chevron-down select-icon"></i> <option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mureg-body"> <div class="mureg-body">
<div class="groups-container"> <div class="groups-container">
<div class="text-center p-5" *ngIf="loading"><span class="spinner-border text-brand"></span></div> <div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!loading && pagedClientGroups.length === 0"> <div class="empty-group" *ngIf="!loading && pagedClientGroups.length === 0">
Nenhum dado encontrado. Nenhum dado encontrado.
</div> </div>
<div class="group-list" *ngIf="!loading"> <div class="group-list" *ngIf="!loading">
<div *ngFor="let g of pagedClientGroups" class="client-group-card" [class.expanded]="expandedGroup === g.cliente"> <div
*ngFor="let g of pagedClientGroups"
class="client-group-card"
[class.expanded]="expandedGroup === g.cliente"
>
<div class="group-header" (click)="toggleGroup(g.cliente)"> <div class="group-header" (click)="toggleGroup(g.cliente)">
<div class="group-info"> <div class="group-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6> <h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges"> <div class="group-badges">
<span class="badge-pill total">{{ g.total }} Registros</span> <span class="badge-pill total">{{ g.total }} Registros</span>
<span class="badge-pill swap" *ngIf="g.trocas > 0">{{ g.trocas }} Trocas</span> <span class="badge-pill swap" *ngIf="g.trocas > 0">{{ g.trocas }} Trocas</span>
@ -110,66 +133,90 @@
<span class="badge-pill warn" *ngIf="g.semIccid > 0">{{ g.semIccid }} Sem ICCID</span> <span class="badge-pill warn" *ngIf="g.semIccid > 0">{{ g.semIccid }} Sem ICCID</span>
</div> </div>
</div> </div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
<div class="group-toggle-icon">
<i class="bi bi-chevron-down"></i>
</div>
</div> </div>
<div class="group-body" *ngIf="expandedGroup === g.cliente"> <div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white"> <div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Registros do Cliente</small> <small class="text-muted fw-bold">Registros do Cliente</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Use o botão à direita para editar</span> <span class="chip-muted">
</div> <i class="bi bi-info-circle me-1"></i> Use o botão à direita para editar
</span>
</div>
<div class="table-wrap inner-table-wrap"> <div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0"> <table class="table table-modern align-middle text-center mb-0">
<thead> <thead>
<tr> <tr>
<th>ITEM</th> <th>ITEM</th>
<th>LINHA ANTIGA</th> <th>LINHA ANTIGA</th>
<th>LINHA NOVA</th> <th>LINHA NOVA</th>
<th>ICCID</th> <th>ICCID</th>
<th>DATA MUREG</th> <th>DATA MUREG</th>
<th>SITUAÇÃO</th> <th>SITUAÇÃO</th>
<th style="min-width: 80px;">AÇÕES</th> <th style="min-width: 80px;">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody>
<tr *ngIf="groupRows.length === 0"> <tbody>
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro.</td> <tr *ngIf="groupRows.length === 0">
</tr> <td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item"> Nenhum registro.
<td class="text-muted fw-bold">{{ r.item || '-' }}</td> </td>
<td class="text-dark">{{ r.linhaAntiga || '-' }}</td> </tr>
<td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
<td class="small font-monospace">{{ r.iccid || '-' }}</td> <tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted small fw-bold">{{ displayValue('dataDaMureg', r.dataDaMureg) }}</td> <td class="text-muted fw-bold">{{ r.item || '-' }}</td>
<td> <td class="text-dark">{{ r.linhaAntiga || '-' }}</td>
<span class="status-pill" [class.is-swap]="isTroca(r)" [class.is-same]="!isTroca(r)"> <td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
{{ isTroca(r) ? 'TROCA' : 'SEM TROCA' }} <td class="small font-monospace">{{ r.iccid || '-' }}</td>
</span> <td class="text-muted small fw-bold">{{ displayValue('dataDaMureg', r.dataDaMureg) }}</td>
</td> <td>
<td> <span class="status-pill" [class.is-swap]="isTroca(r)" [class.is-same]="!isTroca(r)">
<div class="action-group justify-content-center"> {{ isTroca(r) ? 'TROCA' : 'SEM TROCA' }}
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro"><i class="bi bi-pencil-square"></i></button> </span>
</div> </td>
</td> <td>
</tr> <div class="action-group justify-content-center">
</tbody> <button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
</table> <i class="bi bi-pencil-square"></i>
</div> </button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mureg-footer"> <div class="mureg-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} Clientes</div> <div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} Clientes</div>
<nav><ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading"><button class="page-link" (click)="goToPage(page - 1)">Anterior</button></li> <nav>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page"><button class="page-link" (click)="goToPage(p)">{{ p }}</button></li> <ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === totalPages || loading"><button class="page-link" (click)="goToPage(page + 1)">Próxima</button></li> <li class="page-item" [class.disabled]="page === 1 || loading">
</ul></nav> <button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="page === totalPages || loading">
<button class="page-link" (click)="goToPage(page + 1)">Próxima</button>
</li>
</ul>
</nav>
</div> </div>
</div> </div>
@ -178,115 +225,245 @@
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div> <div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<!-- ============================== -->
<!-- EDIT MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="editOpen"> <div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()"> <div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title"> <div class="modal-header">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span> <div class="modal-title">
Editar Registro Mureg <span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
</div> Editar Registro Mureg
<div class="d-flex align-items-center gap-2"> </div>
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar <div class="d-flex align-items-center gap-2">
</button> <button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving"> <i class="bi bi-x-lg me-1"></i> Cancelar
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span> </button>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button> <button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
</div> <span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
</div> <span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
<div class="modal-body modern-body bg-light-gray"> </button>
<ng-container *ngIf="editModel; else editLoadingTpl"> </div>
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Nome do Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataDaMureg" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" />
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl><div class="p-5 text-center text-muted">Preparando edição...</div></ng-template>
</div>
</div> </div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-card-text me-2"></i> Informações</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.selectedClient"
(change)="onEditClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select da Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.mobileLineId"
(change)="onEditLineChange()"
[disabled]="!editModel.selectedClient || editLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsEdit" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataDaMureg" />
</div>
<!-- LinhaAntiga (snapshot) - preenchido automaticamente -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="editModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ editModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">
<span class="spinner-border me-2"></span> Preparando edição...
</div>
</ng-template>
</div>
</div>
</div> </div>
<!-- ============================== -->
<!-- CREATE MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="createOpen"> <div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()"> <div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title"> <div class="modal-header">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span> <div class="modal-title">
Nova Mureg <span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
</div> Nova Mureg
<div class="d-flex align-items-center gap-2"> </div>
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar <div class="d-flex align-items-center gap-2">
</button> <button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving"> <i class="bi bi-x-lg me-1"></i> Cancelar
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span> </button>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button> <button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
</div> <span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
</div> <span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
<div class="modal-body modern-body bg-light-gray"> </button>
<div class="details-dashboard"> </div>
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-pencil me-2"></i> Preencha os dados</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Nome do Cliente <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" placeholder="Nome do Cliente" />
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova <span class="text-danger">*</span></label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" />
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header">
<span><i class="bi bi-pencil me-2"></i> Preencha os dados</span>
</div>
<div class="box-body">
<div class="form-grid">
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.selectedClient"
(change)="onCreateClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
</small>
</div>
<!-- Linha Antiga (select Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.mobileLineId"
(change)="onCreateLineChange()"
[disabled]="!createModel.selectedClient || createLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsCreate" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
</small>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" />
</div>
<!-- snapshot -->
<div class="form-field">
<label>Linha Antiga (snapshot)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<div class="form-field">
<label>Linha Nova <span class="text-danger">*</span></label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<!-- ICCID auto do GERAL -->
<div class="form-field span-2">
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
</div>
<div class="mt-3" *ngIf="createModel?.clienteInfo">
<small class="text-muted fw-bold">
<i class="bi bi-info-circle me-1"></i>
{{ createModel.clienteInfo }}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -9,25 +9,11 @@ import {
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common'; import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
HttpClient, import { LinesService } from '../../services/lines.service';
HttpClientModule,
HttpParams
} from '@angular/common/http';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente'; type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
interface MuregRow {
id: string;
item: string;
linhaAntiga: string;
linhaNova: string;
iccid: string;
dataDaMureg: string;
cliente: string;
raw: any;
}
interface ApiPagedResult<T> { interface ApiPagedResult<T> {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@ -43,6 +29,48 @@ interface ClientGroup {
semIccid: number; semIccid: number;
} }
interface MuregRow {
id: string;
item: string;
linhaAntiga: string;
linhaNova: string;
iccid: string;
dataDaMureg: string;
cliente: string;
mobileLineId: string;
raw: any;
}
/** ✅ AGORA COM item/usuario/chip (igual Troca de Número) */
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
chip: string | null; // => ICCID
usuario: string | null;
cliente?: string | null;
skil?: string | null;
}
interface MuregDetailDto {
id: string;
item: number;
linhaAntiga: string | null;
linhaNova: string | null;
iccid: string | null;
dataDaMureg: string | null;
mobileLineId: string;
cliente: string | null;
usuario: string | null;
skil: string | null;
linhaAtualNaGeral: string | null;
chipNaGeral: string | null;
contaNaGeral: string | null;
statusNaGeral: string | null;
}
@Component({ @Component({
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule], imports: [CommonModule, FormsModule, HttpClientModule],
@ -58,7 +86,8 @@ export class Mureg implements AfterViewInit {
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient, private http: HttpClient,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef,
private linesService: LinesService
) {} ) {}
private readonly apiBase = 'https://localhost:7205/api/mureg'; private readonly apiBase = 'https://localhost:7205/api/mureg';
@ -68,9 +97,8 @@ export class Mureg implements AfterViewInit {
pagedClientGroups: ClientGroup[] = []; pagedClientGroups: ClientGroup[] = [];
expandedGroup: string | null = null; expandedGroup: string | null = null;
groupRows: MuregRow[] = []; groupRows: MuregRow[] = [];
private rowsByClient = new Map<string, MuregRow[]>(); private rowsByClient = new Map<string, MuregRow[]>();
// KPIs // KPIs
groupLoadedRecords = 0; groupLoadedRecords = 0;
groupTotalTrocas = 0; groupTotalTrocas = 0;
@ -83,6 +111,19 @@ export class Mureg implements AfterViewInit {
pageSize = 10; pageSize = 10;
total = 0; total = 0;
// ====== OPTIONS (GERAL) ======
clientOptions: string[] = [];
// create options
lineOptionsCreate: LineOptionDto[] = [];
createClientsLoading = false;
createLinesLoading = false;
// edit options
lineOptionsEdit: LineOptionDto[] = [];
editClientsLoading = false;
editLinesLoading = false;
// ====== EDIT MODAL ====== // ====== EDIT MODAL ======
editOpen = false; editOpen = false;
editSaving = false; editSaving = false;
@ -92,18 +133,23 @@ export class Mureg implements AfterViewInit {
createOpen = false; createOpen = false;
createSaving = false; createSaving = false;
createModel: any = { createModel: any = {
cliente: '', selectedClient: '',
item: '', mobileLineId: '',
linhaAntiga: '', item: '',
linhaNova: '', linhaAntiga: '',
iccid: '', linhaNova: '',
dataDaMureg: '' iccid: '',
dataDaMureg: '',
clienteInfo: ''
}; };
async ngAfterViewInit() { async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
setTimeout(() => { this.refresh(); }); setTimeout(() => {
this.preloadClients(); // ✅ já deixa o select pronto
this.refresh();
});
} }
private initAnimations() { private initAnimations() {
@ -148,6 +194,7 @@ export class Mureg implements AfterViewInit {
} }
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; } get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get pageNumbers() { get pageNumbers() {
const total = this.totalPages; const total = this.totalPages;
const current = this.page; const current = this.page;
@ -159,7 +206,9 @@ export class Mureg implements AfterViewInit {
for (let i = start; i <= end; i++) pages.push(i); for (let i = start; i <= end; i++) pages.push(i);
return pages; return pages;
} }
get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageEnd() { get pageEnd() {
if (this.total === 0) return 0; if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total); return Math.min(this.page * this.pageSize, this.total);
@ -168,7 +217,7 @@ export class Mureg implements AfterViewInit {
trackById(_: number, row: MuregRow) { return row.id; } trackById(_: number, row: MuregRow) { return row.id; }
// ======================================================================= // =======================================================================
// LOAD LOGIC // LOAD LOGIC (lista e grupos)
// ======================================================================= // =======================================================================
private loadForGroups() { private loadForGroups() {
this.loading = true; this.loading = true;
@ -218,8 +267,10 @@ export class Mureg implements AfterViewInit {
const trocas = arr.filter(x => this.isTroca(x)).length; const trocas = arr.filter(x => this.isTroca(x)).length;
const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length; const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length;
const semIccid = total - comIccid; const semIccid = total - comIccid;
trocasTotal += trocas; trocasTotal += trocas;
iccidsTotal += comIccid; iccidsTotal += comIccid;
groups.push({ cliente, total, trocas, comIccid, semIccid }); groups.push({ cliente, total, trocas, comIccid, semIccid });
}); });
@ -236,6 +287,7 @@ export class Mureg implements AfterViewInit {
const start = (this.page - 1) * this.pageSize; const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize; const end = start + this.pageSize;
this.pagedClientGroups = this.clientGroups.slice(start, end); this.pagedClientGroups = this.clientGroups.slice(start, end);
if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) { if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) {
this.expandedGroup = null; this.expandedGroup = null;
this.groupRows = []; this.groupRows = [];
@ -248,6 +300,7 @@ export class Mureg implements AfterViewInit {
this.groupRows = []; this.groupRows = [];
return; return;
} }
this.expandedGroup = cliente; this.expandedGroup = cliente;
const rows = this.rowsByClient.get(cliente) ?? []; const rows = this.rowsByClient.get(cliente) ?? [];
this.groupRows = [...rows].sort((a, b) => { this.groupRows = [...rows].sort((a, b) => {
@ -279,6 +332,7 @@ export class Mureg implements AfterViewInit {
const iccid = pick(x, ['iccid', 'ICCID']); const iccid = pick(x, ['iccid', 'ICCID']);
const dataDaMureg = pick(x, ['dataDaMureg', 'data_da_mureg', 'DATA DA MUREG']); const dataDaMureg = pick(x, ['dataDaMureg', 'data_da_mureg', 'DATA DA MUREG']);
const cliente = pick(x, ['cliente', 'CLIENTE']); const cliente = pick(x, ['cliente', 'CLIENTE']);
const mobileLineId = String(pick(x, ['mobileLineId', 'MobileLineId', 'mobile_line_id']) ?? '');
const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`); const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`);
return { return {
@ -289,131 +343,352 @@ export class Mureg implements AfterViewInit {
iccid: String(iccid ?? ''), iccid: String(iccid ?? ''),
dataDaMureg: String(dataDaMureg ?? ''), dataDaMureg: String(dataDaMureg ?? ''),
cliente: String(cliente ?? ''), cliente: String(cliente ?? ''),
mobileLineId,
raw: x raw: x
}; };
} }
// ====== MODAL EDIÇÃO ====== // =======================================================================
// CLIENTS / LINES OPTIONS (GERAL)
// =======================================================================
private preloadClients() {
if (this.clientOptions.length > 0) return;
// 1. Abrir modal this.createClientsLoading = true;
onEditar(r: MuregRow) { this.editClientsLoading = true;
this.editOpen = true;
this.editSaving = false; this.linesService.getClients().subscribe({
next: (list) => {
this.editModel = { this.clientOptions = (list ?? []).filter(x => !!String(x ?? '').trim());
id: r.id, this.createClientsLoading = false;
item: r.item, this.editClientsLoading = false;
linhaAntiga: r.linhaAntiga, this.cdr.detectChanges();
linhaNova: r.linhaNova, },
iccid: r.iccid, error: async () => {
cliente: r.cliente, this.createClientsLoading = false;
dataDaMureg: this.isoToDateInput(r.dataDaMureg) this.editClientsLoading = false;
}; await this.showToast('Erro ao carregar clientes da GERAL.');
}
});
}
private loadLinesForClient(cliente: string, target: 'create' | 'edit') {
const c = (cliente ?? '').trim();
if (!c) {
if (target === 'create') this.lineOptionsCreate = [];
else this.lineOptionsEdit = [];
return;
}
if (target === 'create') {
this.createLinesLoading = true;
this.lineOptionsCreate = [];
} else {
this.editLinesLoading = true;
this.lineOptionsEdit = [];
}
// ✅ aqui assumimos que o getLinesByClient retorna (id,item,linha,chip,usuario...)
this.linesService.getLinesByClient(c).subscribe({
next: (items: any[]) => {
const mapped: LineOptionDto[] = (items ?? [])
.filter(x => !!String(x?.id ?? '').trim())
.map(x => ({
id: String(x.id),
item: Number(x.item ?? 0),
linha: x.linha ?? null,
chip: x.chip ?? null,
usuario: x.usuario ?? null,
cliente: x.cliente ?? null,
skil: x.skil ?? null
}))
.filter(x => !!String(x.linha ?? '').trim());
if (target === 'create') {
this.lineOptionsCreate = mapped;
this.createLinesLoading = false;
} else {
this.lineOptionsEdit = mapped;
this.editLinesLoading = false;
}
this.cdr.detectChanges();
},
error: async () => {
if (target === 'create') this.createLinesLoading = false;
else this.editLinesLoading = false;
await this.showToast('Erro ao carregar linhas do cliente (GERAL).');
}
});
}
private applySelectedLineToModel(model: any, selectedId: string, options: LineOptionDto[]) {
const id = String(selectedId ?? '').trim();
const opt = options.find(x => x.id === id);
if (!opt) return;
// ✅ snapshot automático (linha antiga)
model.linhaAntiga = opt.linha ?? '';
// ✅ ICCID automático (chip do GERAL)
model.iccid = opt.chip ?? '';
// ✅ se item estiver vazio, preenche com item da GERAL (igual Troca)
if (!String(model.item ?? '').trim() && opt.item) {
model.item = String(opt.item);
}
model.clienteInfo = `Vínculo (GERAL): ${model.selectedClient}${opt.item}${opt.linha ?? '-'}${opt.usuario ?? 'SEM USUÁRIO'}`;
}
// =======================================================================
// CREATE MODAL
// =======================================================================
onCreate() {
this.preloadClients();
this.createOpen = true;
this.createSaving = false;
this.createModel = {
selectedClient: '',
mobileLineId: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: '',
clienteInfo: ''
};
this.lineOptionsCreate = [];
}
closeCreate() {
this.createOpen = false;
}
onCreateClientChange() {
const c = (this.createModel.selectedClient ?? '').trim();
this.createModel.mobileLineId = '';
this.createModel.linhaAntiga = '';
this.createModel.iccid = '';
this.createModel.clienteInfo = c ? `Cliente selecionado: ${c}` : '';
this.loadLinesForClient(c, 'create');
}
onCreateLineChange() {
this.applySelectedLineToModel(this.createModel, this.createModel.mobileLineId, this.lineOptionsCreate);
}
saveCreate() {
const mobileLineId = String(this.createModel.mobileLineId ?? '').trim();
const linhaNova = String(this.createModel.linhaNova ?? '').trim();
if (!mobileLineId || !linhaNova) {
this.showToast('Selecione Cliente + Linha Antiga (GERAL) e preencha Linha Nova.');
return;
}
this.createSaving = true;
const payload: any = {
item: this.toIntOrZero(this.createModel.item),
mobileLineId,
linhaAntiga: (this.createModel.linhaAntiga ?? '') || null,
linhaNova: (this.createModel.linhaNova ?? '') || null,
iccid: (this.createModel.iccid ?? '') || null,
dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg)
};
if (!payload.item || payload.item <= 0) delete payload.item;
this.http.post(this.apiBase, payload).subscribe({
next: async () => {
this.createSaving = false;
await this.showToast('Mureg criada com sucesso!');
this.closeCreate();
this.loadForGroups();
},
error: async (err) => {
this.createSaving = false;
const msg = this.extractApiMessage(err) ?? 'Erro ao criar Mureg.';
await this.showToast(msg);
}
});
}
// =======================================================================
// EDIT MODAL
// =======================================================================
onEditar(r: MuregRow) {
this.preloadClients();
this.editOpen = true;
this.editSaving = false;
this.editModel = null;
this.lineOptionsEdit = [];
this.http.get<MuregDetailDto>(`${this.apiBase}/${r.id}`).subscribe({
next: (d) => {
const selectedClient = String(d?.cliente ?? '').trim();
this.editModel = {
id: d.id,
item: String(d.item ?? ''),
dataDaMureg: this.isoToDateInput(d.dataDaMureg),
// snapshot atual
linhaAntiga: String(d.linhaAntiga ?? d.linhaAtualNaGeral ?? ''),
linhaNova: String(d.linhaNova ?? ''),
// ✅ se não tiver iccid salvo, usa chip da geral
iccid: String(d.iccid ?? d.chipNaGeral ?? ''),
mobileLineId: String(d.mobileLineId ?? ''),
selectedClient,
clienteInfo: selectedClient
? `GERAL: ${selectedClient} • Linha atual: ${d.linhaAtualNaGeral ?? '-'} • Usuário: ${d.usuario ?? '-'} • Chip: ${d.chipNaGeral ?? '-'}`
: ''
};
if (selectedClient) {
this.loadLinesForClient(selectedClient, 'edit');
}
this.cdr.detectChanges();
},
error: async () => {
this.editOpen = false;
await this.showToast('Erro ao abrir detalhes do registro.');
}
});
} }
// 2. Fechar modal
closeEdit() { closeEdit() {
this.editOpen = false; this.editOpen = false;
this.editModel = null; this.editModel = null;
this.editSaving = false; this.editSaving = false;
} }
// 3. Salvar (PUT) onEditClientChange() {
const c = (this.editModel?.selectedClient ?? '').trim();
this.editModel.mobileLineId = '';
this.editModel.linhaAntiga = '';
this.editModel.iccid = '';
this.editModel.clienteInfo = c ? `Cliente selecionado: ${c}` : '';
this.loadLinesForClient(c, 'edit');
}
onEditLineChange() {
if (!this.editModel) return;
this.applySelectedLineToModel(this.editModel, this.editModel.mobileLineId, this.lineOptionsEdit);
}
saveEdit() { saveEdit() {
if(!this.editModel || !this.editModel.id) return; if (!this.editModel || !this.editModel.id) return;
this.editSaving = true;
const payload = { const mobileLineId = String(this.editModel.mobileLineId ?? '').trim();
...this.editModel, if (!mobileLineId) {
dataDaMureg: this.dateInputToIso(this.editModel.dataDaMureg) this.showToast('Selecione Cliente e Linha Antiga (GERAL).');
}; return;
}
this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({ this.editSaving = true;
next: async () => {
this.editSaving = false;
await this.showToast('Registro atualizado com sucesso!');
this.closeEdit();
const currentGroup = this.expandedGroup;
this.loadForGroups();
if(currentGroup) setTimeout(() => this.expandedGroup = currentGroup, 400);
},
error: async () => {
this.editSaving = false;
await this.showToast('Erro ao salvar edição.');
}
});
}
// ====== MODAL CRIAÇÃO ====== const payload: any = {
item: this.toIntOrNull(this.editModel.item),
mobileLineId,
linhaAntiga: (this.editModel.linhaAntiga ?? '') || null,
linhaNova: (this.editModel.linhaNova ?? '') || null,
iccid: (this.editModel.iccid ?? '') || null,
dataDaMureg: this.dateInputToIso(this.editModel.dataDaMureg)
};
onCreate() { if (payload.item == null) delete payload.item;
this.createOpen = true;
this.createSaving = false;
this.createModel = {
cliente: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: ''
};
}
closeCreate() { this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({
this.createOpen = false; next: async () => {
} this.editSaving = false;
await this.showToast('Registro atualizado com sucesso!');
const currentGroup = this.expandedGroup;
this.closeEdit();
this.loadForGroups();
saveCreate() { if (currentGroup) {
if(!this.createModel.cliente || !this.createModel.linhaNova) { setTimeout(() => {
this.showToast('Preencha Cliente e Linha Nova.'); this.expandedGroup = currentGroup;
return; this.toggleGroup(currentGroup);
}, 400);
}
},
error: async (err) => {
this.editSaving = false;
const msg = this.extractApiMessage(err) ?? 'Erro ao salvar edição.';
await this.showToast(msg);
} }
this.createSaving = true; });
}
const payload = {
...this.createModel, // =======================================================================
dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg) // Helpers
}; // =======================================================================
private toIntOrZero(val: any): number {
this.http.post(this.apiBase, payload).subscribe({ const n = parseInt(String(val ?? '').trim(), 10);
next: async () => { return Number.isFinite(n) ? n : 0;
this.createSaving = false; }
await this.showToast('Mureg criada com sucesso!');
this.closeCreate(); private toIntOrNull(val: any): number | null {
this.loadForGroups(); const s = String(val ?? '').trim();
}, if (!s) return null;
error: async () => { const n = parseInt(s, 10);
this.createSaving = false; return Number.isFinite(n) ? n : null;
await this.showToast('Erro ao criar Mureg.');
}
});
} }
// Helpers de Data
private isoToDateInput(iso: string | null | undefined): string { private isoToDateInput(iso: string | null | undefined): string {
if(!iso) return ''; if (!iso) return '';
const dt = new Date(iso); const dt = new Date(iso);
if(Number.isNaN(dt.getTime())) return ''; if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0,10); return dt.toISOString().slice(0, 10);
} }
private dateInputToIso(val: string | null | undefined): string | null { private dateInputToIso(val: string | null | undefined): string | null {
if(!val) return null; if (!val) return null;
const dt = new Date(val); const dt = new Date(val);
if(Number.isNaN(dt.getTime())) return null; if (Number.isNaN(dt.getTime())) return null;
return dt.toISOString(); return dt.toISOString();
}
private extractApiMessage(err: any): string | null {
try {
const m1 = err?.error?.message;
if (m1) return String(m1);
const m2 = err?.error?.title;
if (m2) return String(m2);
return null;
} catch {
return null;
}
} }
displayValue(key: MuregKey, v: any): string { displayValue(key: MuregKey, v: any): string {
if (v === null || v === undefined || String(v).trim() === '') return '-'; if (v === null || v === undefined || String(v).trim() === '') return '-';
if (key === 'dataDaMureg') { if (key === 'dataDaMureg') {
const s = String(v).trim(); const s = String(v).trim();
const d = new Date(s); const d = new Date(s);
if (!Number.isNaN(d.getTime())) { if (!Number.isNaN(d.getTime())) return new Intl.DateTimeFormat('pt-BR').format(d);
return new Intl.DateTimeFormat('pt-BR').format(d);
}
return s; return s;
} }
return String(v); return String(v);
} }
@ -422,12 +697,16 @@ export class Mureg implements AfterViewInit {
this.toastMessage = message; this.toastMessage = message;
this.cdr.detectChanges(); this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return; if (!this.successToast?.nativeElement) return;
try { try {
const bs = await import('bootstrap'); const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { autohide: true, delay: 3000 }); const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, {
autohide: true,
delay: 3000
});
toastInstance.show(); toastInstance.show();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} }
} }

View File

@ -0,0 +1,275 @@
<section class="relatorios-page">
<div class="wrap">
<div class="container">
<div class="page-head fade-in-up">
<div class="title">
<span class="badge">
<i class="bi bi-bar-chart-fill"></i> Relatórios
</span>
<p class="subtitle">Resumo e indicadores do ambiente.</p>
</div>
<div class="status" *ngIf="loading">
<i class="bi bi-arrow-repeat spin"></i>
<span>Carregando...</span>
</div>
<div class="status warn" *ngIf="!loading && errorMsg">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ errorMsg }}</span>
</div>
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card lift" *ngFor="let k of kpis; let i = index" [style.animationDelay.ms]="i * 40">
<div class="kpi-icon">
<i [class]="k.icon"></i>
</div>
<div class="kpi-content">
<div class="kpi-title">{{ k.title }}</div>
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-hint" *ngIf="k.hint">{{ k.hint }}</div>
</div>
</div>
</div>
<!-- Status das linhas -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-pie-chart-fill"></i>
Status das linhas
</div>
</div>
<div class="status-pie-grid">
<div class="pie-wrap">
<canvas #chartStatusPie></canvas>
</div>
<div class="status-metrics">
<div class="metric total">
<span class="dot d1"></span>
<div class="meta">
<div class="k">Total linhas</div>
<div class="v">{{ statusResumo.total | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d2"></span>
<div class="meta">
<div class="k">Ativas</div>
<div class="v">{{ statusResumo.ativos | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d3"></span>
<div class="meta">
<div class="k">Bloqueadas (perda/roubo)</div>
<div class="v">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d4"></span>
<div class="meta">
<div class="k">Bloqueadas (120 dias)</div>
<div class="v">{{ statusResumo.bloq120 | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d5"></span>
<div class="meta">
<div class="k">Reservas</div>
<div class="v">{{ statusResumo.reservas | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d6"></span>
<div class="meta">
<div class="k">Bloqueadas (outros)</div>
<div class="v">{{ statusResumo.outras | number:'1.0-0' }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- ✅ NOVO POSICIONAMENTO: VIGÊNCIA abaixo de Status e acima dos gráficos 12 meses -->
<div class="charts-grid charts-vigencia">
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-calendar2-check"></i>
Contratos a encerrar (próximos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartVigenciaMesAno></canvas>
</div>
</div>
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-shield-exclamation"></i>
Vigência (supervisão)
</div>
</div>
<div class="chart-wrap">
<canvas #chartVigenciaSupervisao></canvas>
</div>
</div>
</div>
<!-- Charts (12 meses) -->
<div class="charts-grid">
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-arrow-repeat"></i>
MUREG (últimos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartMureg12></canvas>
</div>
</div>
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-shuffle"></i>
Troca de número (últimos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartTroca12></canvas>
</div>
</div>
</div>
<!-- Top Clientes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-trophy-fill"></i>
Top clientes (por linhas)
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>#</th>
<th>Cliente</th>
<th>Linhas</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!topClientes || topClientes.length === 0)">
<td colspan="3" class="muted">Nenhum dado encontrado.</td>
</tr>
<tr *ngFor="let c of topClientes; let i = index">
<td>{{ i + 1 }}</td>
<td class="cell-strong">{{ c.cliente }}</td>
<td>{{ c.linhas }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- MUREGs recentes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-clock-history"></i>
MUREGs recentes
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>Item</th>
<th>Linha antiga</th>
<th>Linha nova</th>
<th>ICCID</th>
<th>Cliente</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!muregsRecentes || muregsRecentes.length === 0)">
<td colspan="6" class="muted">Nenhum registro recente.</td>
</tr>
<tr *ngFor="let m of muregsRecentes">
<td>{{ m.item }}</td>
<td>{{ m.linhaAntiga || '-' }}</td>
<td class="cell-strong">{{ m.linhaNova || '-' }}</td>
<td>{{ m.iccid || '-' }}</td>
<td>{{ m.cliente || '-' }}</td>
<td>{{ m.dataDaMureg ? (m.dataDaMureg | date:'dd/MM/yyyy') : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Trocas recentes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-clock-history"></i>
Trocas recentes
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>Item</th>
<th>Linha antiga</th>
<th>Linha nova</th>
<th>ICCID</th>
<th>Motivo</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!trocasRecentes || trocasRecentes.length === 0)">
<td colspan="6" class="muted">Nenhum registro recente.</td>
</tr>
<tr *ngFor="let t of trocasRecentes">
<td>{{ t.item }}</td>
<td>{{ t.linhaAntiga || '-' }}</td>
<td class="cell-strong">{{ t.linhaNova || '-' }}</td>
<td>{{ t.iccid || '-' }}</td>
<td class="cell-clip" [title]="t.motivo || ''">{{ t.motivo || '-' }}</td>
<td>{{ t.dataTroca ? (t.dataTroca | date:'dd/MM/yyyy') : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- sem footer / sem foot-space -->
</div>
</div>
</section>

View File

@ -0,0 +1,341 @@
:host {
display: block;
width: 100%;
overflow-x: hidden;
}
/* ✅ remove footer nessa página */
:host ::ng-deep footer,
:host ::ng-deep .footer,
:host ::ng-deep .portal-footer,
:host ::ng-deep .app-footer {
display: none !important;
}
.relatorios-page {
width: 100%;
overflow-x: hidden;
}
/* ✅ SUBIR MAIS A PÁGINA (antes 44px) */
.wrap {
padding-top: 15px; /* ✅ mais perto do header */
padding-bottom: 16px;
overflow-x: hidden;
}
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 14px;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px; /* ✅ era 14px */
@media (max-width: 900px) {
flex-direction: column;
align-items: stretch;
}
}
.title { min-width: 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
font-weight: 900;
font-family: 'Poppins', sans-serif;
color: rgba(17, 18, 20, 0.9);
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
i {
color: var(--brand-primary);
font-size: 18px;
line-height: 1;
}
}
.subtitle {
margin: 8px 0 0; /* ✅ era 10px */
color: rgba(17, 18, 20, 0.62);
font-weight: 600;
font-size: 13px;
}
.status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
color: rgba(17, 18, 20, 0.78);
font-weight: 700;
font-size: 13px;
i { color: var(--brand-primary); }
&.warn i { color: #d97706; }
}
.spin { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in-up { animation: fadeUp 420ms ease-out both; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.lift {
transition: transform 180ms ease, box-shadow 180ms ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 22px 50px rgba(0,0,0,0.10);
}
}
/* KPIs */
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 10px 0 12px; /* ✅ era 14px 0 16px */
@media (max-width: 1000px) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 520px) { grid-template-columns: 1fr; }
}
.kpi-card {
display: flex;
gap: 12px;
align-items: center;
padding: 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
min-width: 0;
}
.kpi-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--brand-primary), #6a55ff);
color: #fff;
flex: 0 0 auto;
i { font-size: 18px; line-height: 1; }
}
.kpi-content { min-width: 0; }
.kpi-title {
font-weight: 900;
font-size: 12px;
color: rgba(17, 18, 20, 0.65);
}
.kpi-value {
font-weight: 900;
font-size: 18px;
color: rgba(17, 18, 20, 0.92);
margin-top: 2px;
}
.kpi-hint {
margin-top: 4px;
font-size: 12px;
color: rgba(17, 18, 20, 0.55);
font-weight: 700;
}
/* Cards */
.cardx {
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08);
min-width: 0;
margin-top: 10px; /* ✅ era 12px */
}
.cardx-head {
padding: 12px 14px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.55);
border-top-left-radius: 18px;
border-top-right-radius: 18px;
}
.cardx-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 900;
font-family: 'Poppins', sans-serif;
color: rgba(17, 18, 20, 0.86);
i { color: var(--brand-primary); }
}
/* Status grid */
.status-pie-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
padding: 12px;
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.pie-wrap {
position: relative;
height: 260px;
padding: 6px;
canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
}
.status-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-content: start;
@media (max-width: 520px) { grid-template-columns: 1fr; }
}
.metric {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.70);
border: 1px solid rgba(0,0,0,0.06);
}
/* se quiser tirar o rosa do total, troque aqui */
.metric.total .meta .v {
color: #ff2d95;
}
.dot {
width: 10px;
height: 10px;
border-radius: 99px;
flex: 0 0 auto;
}
/* ✅ DOTS COM CORES "PADRÃO DE DASHBOARD" */
.dot.d1 { background: #ff2d95; } /* total (mantém rosa no card de total, se você quiser) */
.dot.d2 { background: #2E7D32; } /* Ativos - verde */
.dot.d3 { background: #D32F2F; } /* Perda/Roubo - vermelho */
.dot.d4 { background: #F57C00; } /* 120 dias - laranja */
.dot.d5 { background: #1976D2; } /* Reservas - azul */
.dot.d6 { background: #607D8B; } /* Outros - cinza */
.meta .k {
font-weight: 900;
font-size: 12px;
color: rgba(17,18,20,0.65);
}
.meta .v {
font-weight: 900;
font-size: 18px;
color: rgba(17,18,20,0.92);
margin-top: 2px;
}
/* Charts */
.charts-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 10px; /* ✅ era 12px */
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.charts-vigencia {
margin-top: 10px; /* ✅ era 12px */
}
.chart-wrap {
position: relative;
height: 320px;
padding: 12px 12px 16px;
overflow: hidden;
canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
}
/* Table */
.table-wrap {
padding: 10px 12px 14px;
overflow-x: auto;
}
.tablex {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.tablex th,
.tablex td {
padding: 10px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-weight: 800;
color: rgba(17, 18, 20, 0.8);
text-align: left;
white-space: nowrap;
}
.tablex th {
color: rgba(17, 18, 20, 0.65);
font-size: 12px;
}
.muted {
color: rgba(17, 18, 20, 0.55);
font-weight: 800;
}
.cell-strong {
font-weight: 900;
color: rgba(17, 18, 20, 0.92);
}
.cell-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,485 @@
import {
Component,
AfterViewInit,
OnInit,
OnDestroy,
ViewChild,
ElementRef,
Inject,
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { PLATFORM_ID } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import Chart from 'chart.js/auto';
type KpiCard = {
title: string;
value: string;
icon: string;
hint?: string;
};
type SerieMesDto = {
mes: string;
total: number;
};
type TopClienteDto = {
cliente: string;
linhas: number;
};
type MuregRecenteDto = {
id: string;
item: number;
linhaAntiga?: string | null;
linhaNova?: string | null;
iccid?: string | null;
dataDaMureg?: string | null;
cliente?: string | null;
mobileLineId: string;
};
type TrocaRecenteDto = {
id: string;
item: number;
linhaAntiga?: string | null;
linhaNova?: string | null;
iccid?: string | null;
dataTroca?: string | null;
motivo?: string | null;
};
type VigenciaBucketsDto = {
vencidos: number;
aVencer0a30: number;
aVencer31a60: number;
aVencer61a90: number;
acima90: number;
};
type DashboardKpisDto = {
totalLinhas: number;
clientesUnicos: number;
ativos: number;
bloqueados: number;
reservas: number;
bloqueadosPerdaRoubo: number;
bloqueados120Dias: number;
bloqueadosOutros: number;
totalMuregs: number;
muregsUltimos30Dias: number;
totalTrocas: number;
trocasUltimos30Dias: number;
totalVigenciaLinhas: number;
vigenciaVencidos: number;
vigenciaAVencer30: number;
userDataRegistros: number;
userDataComCpf: number;
userDataComEmail: number;
};
type RelatoriosDashboardDto = {
kpis: DashboardKpisDto;
topClientes: TopClienteDto[];
serieMuregUltimos12Meses: SerieMesDto[];
serieTrocaUltimos12Meses: SerieMesDto[];
muregsRecentes: MuregRecenteDto[];
trocasRecentes: TrocaRecenteDto[];
// ✅ vigência
serieVigenciaEncerramentosProx12Meses: SerieMesDto[];
vigenciaBuckets: VigenciaBucketsDto;
};
@Component({
selector: 'app-relatorios',
standalone: true,
imports: [CommonModule],
templateUrl: './relatorios.html',
styleUrls: ['./relatorios.scss'],
})
export class Relatorios implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('chartMureg12') chartMureg12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartTroca12') chartTroca12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartStatusPie') chartStatusPie?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaMesAno') chartVigenciaMesAno?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaSupervisao') chartVigenciaSupervisao?: ElementRef<HTMLCanvasElement>;
loading = true;
errorMsg: string | null = null;
kpis: KpiCard[] = [];
muregLabels: string[] = [];
muregValues: number[] = [];
trocaLabels: string[] = [];
trocaValues: number[] = [];
vigenciaLabels: string[] = [];
vigenciaValues: number[] = [];
vigBuckets: VigenciaBucketsDto = {
vencidos: 0,
aVencer0a30: 0,
aVencer31a60: 0,
aVencer61a90: 0,
acima90: 0,
};
topClientes: TopClienteDto[] = [];
muregsRecentes: MuregRecenteDto[] = [];
trocasRecentes: TrocaRecenteDto[] = [];
statusResumo = {
total: 0,
ativos: 0,
perdaRoubo: 0,
bloq120: 0,
reservas: 0,
outras: 0,
};
private viewReady = false;
private dataReady = false;
private chartMureg?: Chart;
private chartTroca?: Chart;
private chartPie?: Chart;
private chartVigMesAno?: Chart;
private chartVigSuper?: Chart;
private readonly baseApi: string;
// ✅ Paletas "padrão de dashboard" (fácil de entender)
private readonly STATUS_COLORS = {
ativos: '#2E7D32', // verde
perdaRoubo: '#D32F2F', // vermelho
bloq120: '#F57C00', // laranja
reservas: '#1976D2', // azul
outros: '#607D8B', // cinza
};
private readonly VIG_COLORS = {
vencidos: '#D32F2F', // vermelho
d0a30: '#F57C00', // laranja
d31a60: '#FBC02D', // amarelo
d61a90: '#1976D2', // azul
acima90: '#2E7D32', // verde
};
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
ngOnInit(): void {
this.loadDashboard();
}
ngAfterViewInit(): void {
this.viewReady = true;
this.tryBuildCharts();
}
ngOnDestroy(): void {
this.destroyCharts();
}
private async loadDashboard() {
this.loading = true;
this.errorMsg = null;
this.dataReady = false;
try {
const dto = await this.fetchDashboardReal();
this.applyDto(dto);
this.dataReady = true;
this.loading = false;
this.tryBuildCharts();
} catch {
this.loading = false;
this.errorMsg =
'Falha ao carregar Relatórios. Verifique se a API está rodando e o endpoint /api/relatorios/dashboard está acessível.';
}
}
private async fetchDashboardReal(): Promise<RelatoriosDashboardDto> {
if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts');
const url = `${this.baseApi}/relatorios/dashboard`;
return await firstValueFrom(this.http.get<RelatoriosDashboardDto>(url));
}
private applyDto(dto: RelatoriosDashboardDto) {
const k = dto.kpis;
this.kpis = [
{ title: 'Linhas', value: this.formatInt(k.totalLinhas), icon: 'bi bi-sim', hint: 'Total cadastradas' },
{ title: 'Clientes', value: this.formatInt(k.clientesUnicos), icon: 'bi bi-building', hint: 'Clientes únicos' },
{ title: 'Ativos', value: this.formatInt(k.ativos), icon: 'bi bi-check2-circle', hint: 'Linhas ativas' },
{ title: 'Bloqueados', value: this.formatInt(k.bloqueados), icon: 'bi bi-slash-circle', hint: 'Somatório de bloqueios' },
{ title: 'Reservas', value: this.formatInt(k.reservas), icon: 'bi bi-inboxes', hint: 'Linhas em reserva' },
{ title: 'MUREGs (30d)', value: this.formatInt(k.muregsUltimos30Dias), icon: 'bi bi-arrow-repeat', hint: 'Últimos 30 dias' },
{ title: 'Trocas (30d)', value: this.formatInt(k.trocasUltimos30Dias), icon: 'bi bi-shuffle', hint: 'Últimos 30 dias' },
{ title: 'Vencidos', value: this.formatInt(k.vigenciaVencidos), icon: 'bi bi-exclamation-triangle', hint: 'Vigência vencida' },
{ title: 'A vencer (30d)', value: this.formatInt(k.vigenciaAVencer30), icon: 'bi bi-calendar2-week', hint: 'Vigência a vencer' },
{ title: 'Registros', value: this.formatInt(k.userDataRegistros), icon: 'bi bi-person-vcard', hint: 'Cadastros de usuário' },
];
this.muregLabels = (dto.serieMuregUltimos12Meses || []).map(x => x.mes);
this.muregValues = (dto.serieMuregUltimos12Meses || []).map(x => x.total);
this.trocaLabels = (dto.serieTrocaUltimos12Meses || []).map(x => x.mes);
this.trocaValues = (dto.serieTrocaUltimos12Meses || []).map(x => x.total);
this.vigenciaLabels = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.mes);
this.vigenciaValues = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.total);
this.vigBuckets = dto.vigenciaBuckets || this.vigBuckets;
this.topClientes = dto.topClientes || [];
this.muregsRecentes = dto.muregsRecentes || [];
this.trocasRecentes = dto.trocasRecentes || [];
this.statusResumo = {
total: k.totalLinhas ?? 0,
ativos: k.ativos ?? 0,
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
bloq120: k.bloqueados120Dias ?? 0,
reservas: k.reservas ?? 0,
outras: k.bloqueadosOutros ?? 0,
};
}
private tryBuildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
if (!this.viewReady || !this.dataReady) return;
requestAnimationFrame(() => {
const canvases = [
this.chartStatusPie?.nativeElement,
this.chartVigenciaMesAno?.nativeElement,
this.chartVigenciaSupervisao?.nativeElement,
this.chartMureg12?.nativeElement,
this.chartTroca12?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[];
if (canvases.length === 0) return;
// evita render quando canvas ainda não mediu (bug comum que "some")
if (canvases.some(c => c.clientWidth === 0 || c.clientHeight === 0)) {
requestAnimationFrame(() => this.tryBuildCharts());
return;
}
this.buildCharts();
});
}
private buildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
this.destroyCharts();
// ✅ Status das linhas (paleta padrão)
const cP = this.chartStatusPie?.nativeElement;
if (cP) {
this.chartPie = new Chart(cP, {
type: 'doughnut',
data: {
labels: [
'Ativos',
'Bloqueadas (perda/roubo)',
'Bloqueadas (120 dias)',
'Reservas',
'Bloqueadas (outros)'
],
datasets: [{
data: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras,
],
borderWidth: 1,
backgroundColor: [
this.STATUS_COLORS.ativos,
this.STATUS_COLORS.perdaRoubo,
this.STATUS_COLORS.bloq120,
this.STATUS_COLORS.reservas,
this.STATUS_COLORS.outros,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ Contratos a encerrar (próximos 12 meses) - barra azul padrão
const cV1 = this.chartVigenciaMesAno?.nativeElement;
if (cV1) {
this.chartVigMesAno = new Chart(cV1, {
type: 'bar',
data: {
labels: this.vigenciaLabels,
datasets: [{
label: 'Encerramentos',
data: this.vigenciaValues,
borderWidth: 0,
backgroundColor: '#1976D2', // azul padrão
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Vigência (supervisão) - paleta por urgência
const cV2 = this.chartVigenciaSupervisao?.nativeElement;
if (cV2) {
this.chartVigSuper = new Chart(cV2, {
type: 'doughnut',
data: {
labels: ['Vencidos', '030 dias', '3160 dias', '6190 dias', '> 90 dias'],
datasets: [{
data: [
this.vigBuckets.vencidos,
this.vigBuckets.aVencer0a30,
this.vigBuckets.aVencer31a60,
this.vigBuckets.aVencer61a90,
this.vigBuckets.acima90,
],
borderWidth: 1,
backgroundColor: [
this.VIG_COLORS.vencidos,
this.VIG_COLORS.d0a30,
this.VIG_COLORS.d31a60,
this.VIG_COLORS.d61a90,
this.VIG_COLORS.acima90,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ MUREG 12 meses
const cM = this.chartMureg12?.nativeElement;
if (cM) {
this.chartMureg = new Chart(cM, {
type: 'bar',
data: {
labels: this.muregLabels,
datasets: [{
label: 'MUREG',
data: this.muregValues,
borderWidth: 0,
backgroundColor: '#6A1B9A', // roxo (bem comum em dashboards)
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Troca 12 meses
const cT = this.chartTroca12?.nativeElement;
if (cT) {
this.chartTroca = new Chart(cT, {
type: 'bar',
data: {
labels: this.trocaLabels,
datasets: [{
label: 'Troca',
data: this.trocaValues,
borderWidth: 0,
backgroundColor: '#00897B', // teal (bem comum)
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
}
private destroyCharts() {
try { this.chartMureg?.destroy(); } catch {}
try { this.chartTroca?.destroy(); } catch {}
try { this.chartPie?.destroy(); } catch {}
try { this.chartVigMesAno?.destroy(); } catch {}
try { this.chartVigSuper?.destroy(); } catch {}
this.chartMureg = undefined;
this.chartTroca = undefined;
this.chartPie = undefined;
this.chartVigMesAno = undefined;
this.chartVigSuper = undefined;
}
private formatInt(v: number) {
return (v || 0).toLocaleString('pt-BR');
}
}

View File

@ -278,7 +278,7 @@
</div> </div>
</div> </div>
<!-- CREATE MODAL --> <!-- CREATE MODAL (✅ BEBENDO DO GERAL) -->
<div class="modal-custom" *ngIf="createOpen"> <div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()"> <div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
@ -306,6 +306,7 @@
<div class="box-body"> <div class="box-body">
<div class="form-grid"> <div class="form-grid">
<div class="form-field"> <div class="form-field">
<label>Item</label> <label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" /> <input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
@ -316,19 +317,60 @@
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataTroca" /> <input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataTroca" />
</div> </div>
<div class="form-field"> <!-- ✅ Cliente (GERAL) -->
<label>Linha Antiga</label> <div class="form-field span-2">
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" /> <label>Cliente (GERAL)</label>
<select class="form-control form-control-sm"
[(ngModel)]="selectedCliente"
(change)="onClienteChange()"
[disabled]="loadingClients">
<option value="">Selecione...</option>
<option *ngFor="let c of clientsFromGeral" [value]="c">{{ c }}</option>
</select>
<small class="hint" *ngIf="loadingClients">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
</small>
</div> </div>
<!-- ✅ Linha do Cliente (GERAL) -->
<div class="form-field span-2">
<label>Linha do Cliente (GERAL)</label>
<select class="form-control form-control-sm"
[(ngModel)]="selectedLineId"
(change)="onLineChange()"
[disabled]="!selectedCliente || loadingLines">
<option value="">Selecione...</option>
<option *ngFor="let l of linesFromClient" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<small class="hint" *ngIf="loadingLines">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
</small>
<small class="hint warn" *ngIf="selectedCliente && !loadingLines && linesFromClient.length === 0">
Nenhuma linha encontrada para este cliente no GERAL.
</small>
</div>
<!-- ✅ Linha Antiga (auto do GERAL) -->
<div class="form-field">
<label>Linha Antiga (auto)</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" readonly />
</div>
<!-- Linha Nova -->
<div class="form-field"> <div class="form-field">
<label>Linha Nova</label> <label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" /> <input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div> </div>
<!-- ✅ ICCID (auto do GERAL) -->
<div class="form-field span-2"> <div class="form-field span-2">
<label>ICCID</label> <label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" /> <input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div> </div>
<div class="form-field span-2"> <div class="form-field span-2">
@ -340,6 +382,7 @@
<label>Observação</label> <label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea> <textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea>
</div> </div>
</div> </div>
</div> </div>

View File

@ -588,7 +588,13 @@
.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; } .box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
.box-body { padding: 16px; } .box-body { padding: 16px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } } .form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
}
.form-field { .form-field {
display: flex; display: flex;
@ -603,7 +609,10 @@
color: rgba(17,18,20,0.65); color: rgba(17,18,20,0.65);
} }
&.span-2 { grid-column: span 2; @media (max-width: 600px) { grid-column: span 1; } } &.span-2 {
grid-column: span 2;
@media (max-width: 600px) { grid-column: span 1; }
}
} }
.form-control { .form-control {
@ -612,3 +621,18 @@
&:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; } &:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; }
} }
/* ✅ HINTS do modal (carregando/avisos) */
.hint {
margin-top: 6px;
font-size: 0.75rem;
font-weight: 800;
color: rgba(17, 18, 20, 0.55);
display: inline-flex;
align-items: center;
gap: 8px;
&.warn {
color: #b58100;
}
}

View File

@ -40,6 +40,17 @@ interface GroupItem {
semIccid: number; semIccid: number;
} }
/** ✅ DTO da linha do GERAL (para selects) */
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
chip: string | null;
cliente: string | null;
usuario: string | null;
skil: string | null;
}
@Component({ @Component({
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule], imports: [CommonModule, FormsModule, HttpClientModule],
@ -60,6 +71,9 @@ export class TrocaNumero implements AfterViewInit {
private readonly apiBase = 'https://localhost:7205/api/trocanumero'; private readonly apiBase = 'https://localhost:7205/api/trocanumero';
/** ✅ base do GERAL (para buscar clientes/linhas no modal) */
private readonly linesApiBase = 'https://localhost:7205/api/lines';
// ====== DATA ====== // ====== DATA ======
groups: GroupItem[] = []; groups: GroupItem[] = [];
pagedGroups: GroupItem[] = []; pagedGroups: GroupItem[] = [];
@ -98,6 +112,14 @@ export class TrocaNumero implements AfterViewInit {
observacao: '' observacao: ''
}; };
/** ✅ selects do GERAL no modal */
clientsFromGeral: string[] = [];
linesFromClient: LineOptionDto[] = [];
selectedCliente: string = '';
selectedLineId: string = '';
loadingClients = false;
loadingLines = false;
async ngAfterViewInit() { async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
@ -285,7 +307,7 @@ export class TrocaNumero implements AfterViewInit {
const iccid = pick(x, ['iccid', 'ICCID']); const iccid = pick(x, ['iccid', 'ICCID']);
const dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']); const dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']);
const motivo = pick(x, ['motivo', 'MOTIVO']); const motivo = pick(x, ['motivo', 'MOTIVO']);
const observacao = pick(x, ['observacao', 'OBSERVAÇÃO', 'OBSERVACAO', 'OBSERVACAO']); const observacao = pick(x, ['observacao', 'OBSERVAÇÃO', 'OBSERVACAO']);
const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`); const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`);
@ -302,6 +324,79 @@ export class TrocaNumero implements AfterViewInit {
}; };
} }
// =======================================================================
// ✅ GERAL -> selects do modal (Clientes / Linhas do cliente)
// =======================================================================
private loadClientsFromGeral() {
this.loadingClients = true;
this.http.get<string[]>(`${this.linesApiBase}/clients`).subscribe({
next: (res) => {
this.clientsFromGeral = (res ?? []).filter(x => !!String(x ?? '').trim());
this.loadingClients = false;
this.cdr.detectChanges();
},
error: async () => {
this.loadingClients = false;
await this.showToast('Erro ao carregar clientes do GERAL.');
}
});
}
private loadLinesByClient(cliente: string) {
const c = String(cliente ?? '').trim();
if (!c) {
this.linesFromClient = [];
this.selectedLineId = '';
return;
}
this.loadingLines = true;
const params = new HttpParams().set('cliente', c);
this.http.get<LineOptionDto[]>(`${this.linesApiBase}/by-client`, { params }).subscribe({
next: (res) => {
this.linesFromClient = (res ?? []);
this.loadingLines = false;
this.cdr.detectChanges();
},
error: async () => {
this.loadingLines = false;
await this.showToast('Erro ao carregar linhas do cliente (GERAL).');
}
});
}
onClienteChange() {
// reset quando troca cliente
this.selectedLineId = '';
this.linesFromClient = [];
// limpa campos auto
this.createModel.linhaAntiga = '';
this.createModel.iccid = '';
this.loadLinesByClient(this.selectedCliente);
}
onLineChange() {
const id = String(this.selectedLineId ?? '').trim();
const found = this.linesFromClient.find(x => x.id === id);
// preenche automaticamente a partir do GERAL
this.createModel.linhaAntiga = found?.linha ?? '';
this.createModel.iccid = found?.chip ?? ''; // Chip do GERAL => ICCID aqui
// se quiser, pode setar item automaticamente também:
if (found?.item !== undefined && found?.item !== null) {
// só seta se estiver vazio (pra não atrapalhar quem quiser digitar)
if (!String(this.createModel.item ?? '').trim()) {
this.createModel.item = String(found.item);
}
}
}
// ====== MODAL EDIÇÃO ====== // ====== MODAL EDIÇÃO ======
onEditar(r: TrocaRow) { onEditar(r: TrocaRow) {
this.editOpen = true; this.editOpen = true;
@ -360,6 +455,7 @@ export class TrocaNumero implements AfterViewInit {
this.createOpen = true; this.createOpen = true;
this.createSaving = false; this.createSaving = false;
// reset do form
this.createModel = { this.createModel = {
item: '', item: '',
linhaAntiga: '', linhaAntiga: '',
@ -369,6 +465,15 @@ export class TrocaNumero implements AfterViewInit {
motivo: '', motivo: '',
observacao: '' observacao: ''
}; };
// reset dos selects
this.selectedCliente = '';
this.selectedLineId = '';
this.clientsFromGeral = [];
this.linesFromClient = [];
// carrega clientes do GERAL
this.loadClientsFromGeral();
} }
closeCreate() { closeCreate() {
@ -376,13 +481,27 @@ export class TrocaNumero implements AfterViewInit {
} }
saveCreate() { saveCreate() {
// ✅ validações do "beber do GERAL"
if (!String(this.selectedCliente ?? '').trim()) {
this.showToast('Selecione um Cliente do GERAL.');
return;
}
if (!String(this.selectedLineId ?? '').trim()) {
this.showToast('Selecione uma Linha do Cliente (GERAL).');
return;
}
if (!String(this.createModel.linhaNova ?? '').trim()) {
this.showToast('Informe a Linha Nova.');
return;
}
this.createSaving = true; this.createSaving = true;
const payload = { const payload = {
item: this.toNumberOrNull(this.createModel.item), item: this.toNumberOrNull(this.createModel.item),
linhaAntiga: this.createModel.linhaAntiga, linhaAntiga: this.createModel.linhaAntiga, // auto do GERAL
linhaNova: this.createModel.linhaNova, linhaNova: this.createModel.linhaNova,
iccid: this.createModel.iccid, iccid: this.createModel.iccid, // auto do GERAL
motivo: this.createModel.motivo, motivo: this.createModel.motivo,
observacao: this.createModel.observacao, observacao: this.createModel.observacao,
dataTroca: this.dateInputToIso(this.createModel.dataTroca) dataTroca: this.dateInputToIso(this.createModel.dataTroca)

View File

@ -49,6 +49,11 @@ export interface MobileLineDetail extends MobileLineList {
dataEntregaCliente?: string | null; dataEntregaCliente?: string | null;
} }
export interface LineOption {
id: string;
linha: string;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LinesService { export class LinesService {
// ✅ Mesma base do Swagger (evita redirect no preflight/CORS) // ✅ Mesma base do Swagger (evita redirect no preflight/CORS)
@ -85,7 +90,6 @@ export class LinesService {
return this.http.post<{ imported: number }>(`${this.baseUrl}/import-excel`, form); return this.http.post<{ imported: number }>(`${this.baseUrl}/import-excel`, form);
} }
// (opcional) se você usa groups/clients no Geral, pode manter aqui também:
getClients(skil?: string): Observable<string[]> { getClients(skil?: string): Observable<string[]> {
let params = new HttpParams(); let params = new HttpParams();
const s = (skil ?? '').trim(); const s = (skil ?? '').trim();
@ -106,4 +110,11 @@ export class LinesService {
return this.http.get<PagedResult<any>>(`${this.baseUrl}/groups`, { params }); return this.http.get<PagedResult<any>>(`${this.baseUrl}/groups`, { params });
} }
// ✅ NOVO: usado no modal do MUREG
// Precisa existir no backend: GET /api/lines/by-client?cliente=...
getLinesByClient(cliente: string): Observable<LineOption[]> {
let params = new HttpParams().set('cliente', (cliente ?? '').trim());
return this.http.get<LineOption[]>(`${this.baseUrl}/by-client`, { params });
}
} }

View File

@ -1,7 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { AppComponent } from './app/app';
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; import 'bootstrap/dist/js/bootstrap.bundle.min.js';
bootstrapApplication(App, appConfig) bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err)); .catch((err) => console.error(err));

View File

@ -1,5 +1,105 @@
@import "bootstrap-icons/font/bootstrap-icons.css"; @import "bootstrap-icons/font/bootstrap-icons.css";
body { /* Variáveis baseadas na sua marca, mas modernizadas */
background: #EFEFEF !important; :root {
--brand-primary: #E33DCF;
--brand-hover: #c91eb5;
--brand-soft: rgba(227, 61, 207, 0.08);
--text-main: #0F172A; /* Slate 900 - Preto moderno */
--text-muted: #64748B; /* Slate 500 - Cinza moderno */
--bg-body: #F8FAFC;
--glass-border: rgba(255, 255, 255, 0.6);
--glass-bg: rgba(255, 255, 255, 0.7);
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
--radius-lg: 16px;
--font-sans: 'Inter', sans-serif;
} }
body {
background-color: var(--bg-body);
color: var(--text-main);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
/* Garante scroll da página em todo o app */
overflow-y: auto !important;
}
/* Utilitário de animação suave */
.fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
transform: translateY(20px);
}
@keyframes fadeInUp {
to { opacity: 1; transform: translateY(0); }
}
/* Empurra o conteúdo pra baixo do header fixo */
.app-main.has-header {
padding-top: 84px; /* altura segura p/ header (mobile/desktop) */
}
@media (max-width: 600px) {
.app-main.has-header {
padding-top: 96px;
}
}
/* ========================================================== */
/* 🚀 GLOBAL FIX: Proporção Horizontal e Vertical */
/* ========================================================== */
/* 1. HORIZONTAL: Mantém a proporção mais "fechada" que você gostou */
.container-geral,
.container-fat,
.container-mureg,
.container-troca,
.container-geral-responsive {
max-width: 1100px !important; /* Largura controlada */
width: 96% !important; /* Margem segura em telas menores */
margin-left: auto !important;
margin-right: auto !important;
display: block !important;
}
/* 2. TABELAS: Remove limites de largura mínima desnecessários */
.table-modern {
width: 100% !important;
min-width: unset !important;
}
/* 3. VERTICAL: Libera o crescimento dos cards em todas as páginas */
.geral-card,
.fat-card,
.mureg-card,
.troca-card,
.vigencia-page .geral-card {
height: auto !important; /* Cresce conforme o conteúdo */
max-height: none !important; /* Remove limites fixos */
min-height: 80vh; /* Garante um tamanho mínimo bonito */
overflow: visible !important; /* Remove scroll interno, usa o da janela */
margin-bottom: 40px !important; /* Respiro no final */
}
/* 4. LISTAS E WRAPPERS: Destrava o crescimento interno */
.groups-container,
.table-wrap,
.table-wrap-tall,
.inner-table-wrap {
height: auto !important;
max-height: none !important; /* CRÍTICO: Remove travas de pixels */
overflow: visible !important;
}
/* 5. PÁGINAS: Garante que o scroll do navegador funcione */
.geral-page,
.users-page,
.fat-page,
.mureg-page,
.troca-page {
overflow-y: auto !important;
height: auto !important;
display: block !important;
}