Merge pull request #23 from eduardolopesx03/refatoracao-saas-relatorio

feat: refatorado o código envolvendo boas práticas saas e implementaç…
This commit is contained in:
Eduardo Lopes 2026-01-22 12:32:40 -03:00 committed by GitHub
commit 2b81e7f594
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
// ✅ NOVO: TROCA DE NÚMERO
import { Relatorios } from './pages/relatorios/relatorios';
export const routes: Routes = [
{ path: '', component: Home },
@ -25,9 +23,13 @@ export const routes: Routes = [
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
// ✅ NOVO: rota da página Troca de Número
{ 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: '' },
];

View File

@ -1,16 +1,65 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './components/header/header';
import { Footer } from './components/footer/footer';
// src/app/app.ts
import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Header } from './components/header/header';
import { FooterComponent } from './components/footer/footer';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, Header, Footer],
imports: [
CommonModule,
RouterOutlet,
Header,
FooterComponent
],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrls: ['./app.scss'],
})
export class App {
protected readonly title = signal('line-gestao-frontend');
export class AppComponent {
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="footer-left">
<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>
<p><strong>CNPJ:</strong> 45.470.843/0001-90</p>
<p>Av. Luís Viana Filho, Nº 7532 - Sala 1008</p>
</div>
<div class="container">
<div class="footer-inner">
<div class="footer-right">
<div class="footer-brand">
<div class="logo-text">
Line<span>Gestão</span>
</div>
<p class="footer-tagline">
Inteligência para linhas corporativas.
</p>
</div>
<div class="social-wrapper">
<div class="social-section">
<span class="social-label">Siga-nos</span>
<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>
<a href="#" class="social-icon">
<i class="bi bi-instagram"></i>
</a>
<div class="footer-copy">
<p>
&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-linkedin"></i>
</a>
</div>
</div>
<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 @@
/* ===================================== */
/* FOOTER CONTAINER VERSÃO MODERNA */
/* ===================================== */
.app-footer {
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%;
/* Degradê com as cores da marca */
background: linear-gradient(90deg, #030FAA 0%, #6066FF 45%, #C91EB5 100%);
padding: 10px 32px; /* bem mais baixo que antes */
box-sizing: border-box;
margin-top: -0.5px;
height: 1px;
background: linear-gradient(90deg,
rgba(255,255,255,0) 0%,
rgba(227, 61, 207, 0.3) 50%,
rgba(255,255,255,0) 100%
);
margin-bottom: 32px;
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
gap: 24px;
font-family: "Inter", sans-serif;
color: #FFFFFF;
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) {
/* Responsividade: Empilha no mobile */
@media (max-width: 992px) {
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 12px 20px;
}
@media (max-width: 768px) {
padding: 12px 16px;
gap: 32px;
}
}
/* ===================================== */
/* LADO ESQUERDO (TEXTOS) */
/* ===================================== */
.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) {
/* Identidade */
.footer-brand {
.logo-text {
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 {
opacity: 0.8;
transform: translateY(-1px);
}
/* Botão Política de Privacidade */
.footer-button-wrapper {
/* Navegação Central */
.footer-nav {
display: flex;
justify-content: flex-end;
gap: 24px;
@media (max-width: 1199.98px) {
justify-content: center;
@media (max-width: 576px) {
flex-direction: column;
gap: 12px;
}
@media (max-width: 768px) {
justify-content: flex-start;
a {
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 { Footer } from './footer';
import { FooterComponent } from './footer';
describe('Footer', () => {
let component: Footer;
let fixture: ComponentFixture<Footer>;
let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Footer]
imports: [FooterComponent]
})
.compileComponents();
fixture = TestBed.createComponent(Footer);
fixture = TestBed.createComponent(FooterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

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

View File

@ -1,333 +1,136 @@
:host {
--brand: #E33DCF;
--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;
.app-header {
position: fixed;
top: 0;
z-index: 1200;
/* ✅ 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 {
left: 0;
width: 100%;
height: 72px;
padding: 0 32px;
z-index: 1000;
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;
align-items: center;
justify-content: space-between;
gap: 22px;
@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;
}
gap: 12px;
}
/* ✅ Centralizar menu em telas grandes (desktop) */
@media (min-width: 993px) {
.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 {
/* ✅ LOGADO: hambúrguer + logo lado a lado */
.left-logged {
display: flex;
align-items: center;
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 {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
text-decoration: none;
color: inherit;
cursor: pointer;
color: var(--text-main);
transition: transform 180ms ease, filter 180ms ease;
}
.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;
.logo-icon {
width: 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) {
width: 150px;
height: 40px;
.logo-text {
font-size: 20px;
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-weight: 500;
transition: color 0.2s;
@media (max-width: 600px) {
width: 46vw;
max-width: 190px;
&:hover { color: var(--brand-primary); }
}
}
.btn-cadastrar {
background: #E1E1E1;
color: #000;
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn-login {
background: var(--brand);
border-color: var(--brand);
color: #fff !important;
.btn-login-header {
text-decoration: none;
font-size: 14px;
font-weight: 600;
color: var(--text-main);
padding: 8px 20px;
border-radius: 99px;
background: #fff;
border: 1px solid rgba(0,0,0,0.1);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
transform: translateY(-1px);
}
}
.btn-cadastrar:hover,
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
.btn-icon {
background: rgba(255,255,255,0.75);
border: 1px solid rgba(0,0,0,0.10);
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
cursor: pointer;
backdrop-filter: blur(12px);
transition: transform 0.15s ease, box-shadow 0.15s ease;
i { font-size: 24px; color: var(--text-main); }
&:hover {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(0,0,0,0.12);
}
}
.btn-login:hover {
filter: brightness(0.97);
}
/* ===================== */
/* FAIXA (HOME) */
/* ===================== */
/* Faixa home */
.header-bar {
margin-top: 10px;
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 480px) {
height: 30px;
}
}
.footer-gradient {
background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%);
}
@ -336,38 +139,31 @@
font-size: 15px;
font-weight: 800;
font-family: 'Poppins', sans-serif;
@media (max-width: 480px) { font-size: 13px; }
}
/* ===================================================== */
/* MENU LATERAL (LOGADO) */
/* ===================================================== */
/* ========================= */
/* MENU LATERAL (LOGADO) */
/* ========================= */
.menu-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.35);
background: rgba(0, 0, 0, 0.38);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.side-menu {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: min(340px, 88vw);
width: min(360px, 88vw);
z-index: 1150;
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);
-webkit-backdrop-filter: blur(16px);
border-right: 1px solid rgba(227, 61, 207, 0.18);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.14);
@ -385,31 +181,46 @@
gap: 12px;
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 {
width: 44px;
height: 44px;
width: 42px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: rgba(255, 255, 255, 0.60);
border: 1px solid rgba(0,0,0,0.10);
background: rgba(255, 255, 255, 0.70);
display: grid;
place-items: center;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.close-btn:hover {
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);
i { font-size: 18px; color: rgba(17,18,20,0.7); }
}
.side-menu-body {
@ -420,55 +231,38 @@
.side-item {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
width: 100%;
padding: 12px 12px;
border-radius: 14px;
text-decoration: none;
color: rgba(0, 0, 0, 0.80);
color: rgba(17, 18, 20, 0.86);
font-weight: 800;
font-family: 'Poppins', sans-serif;
transition: background 180ms ease, transform 180ms ease;
}
.side-item i { color: var(--brand); }
.side-item:hover {
background: rgba(227, 61, 207, 0.10);
transform: translateY(-1px);
}
.side-item:active {
transform: translateY(0) scale(0.99);
}
/* ========================================= */
/* OVERRIDE BOOTSTRAP */
/* ========================================= */
.btn.btn-cadastrar,
.btn.btn-login {
-webkit-tap-highlight-color: transparent;
}
.btn.btn-cadastrar:active,
.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;
i {
font-size: 16px;
color: var(--brand-primary);
width: 18px;
text-align: center;
line-height: 1;
}
/* ✅ polimento: deixa o bar-chart com “peso” igual aos outros ícones */
.bi-bar-chart-fill {
font-size: 17px;
}
&:hover {
background: rgba(227, 61, 207, 0.10);
transform: translateY(-1px);
}
&.active {
background: rgba(3, 15, 170, 0.10);
border: 1px solid rgba(3, 15, 170, 0.12);
}
}

View File

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

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 -->
<span class="page-blob blob-1" aria-hidden="true"></span>
<span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="home-global-bg">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="mesh-overlay"></div>
</div>
<!-- HERO -->
<section class="hero">
<div class="container">
<div class="content-scroll">
<div class="hero-inner">
<div class="container hero-container">
<div class="row align-items-center">
<!-- COLUNA TEXTO (CENTRALIZADA) -->
<div class="hero-copy">
<div class="hero-badge" data-animate>
<i class="bi bi-stars"></i>
SaaS para gestão de linhas corporativas
<div class="col-lg-6 hero-content fade-in-up">
<div class="badge-saas">
<span class="pulse"></span> Plataforma Inteligente
</div>
<section class="hero-text-section">
<h1 class="main-title" data-animate>
<span class="first-line">Gerencie suas linhas móveis</span>
<span class="second-line">com <strong>inteligência e praticidade</strong></span>
</h1>
<h1 class="display-title">
Gestão de linhas corporativas <br>
<span class="text-gradient">simples e eficiente.</span>
</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>
<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-actions" data-animate>
<app-cta-button
label="COMEÇAR AGORA"
(clicked)="iniciar()">
</app-cta-button>
<div class="hero-actions">
<button (click)="goToLogin()" class="btn-primary-saas">
Acessar Painel
</button>
<button (click)="scrollToFeatures()" class="btn-secondary-saas">
Ver Recursos
</button>
</div>
<button type="button" class="cta-secondary" (click)="scrollToFeatures()">
<i class="bi bi-arrow-down-circle"></i>
Ver recursos
</button>
</div>
</section>
<div class="trust-strip">
<span><i class="bi bi-shield-check"></i> Dados Seguros</span>
<span><i class="bi bi-lightning-charge"></i> Setup Rápido</span>
</div>
</div>
<!-- CARD/MOCK (fica na direita) -->
<div class="hero-mock" data-animate aria-label="Prévia visual do painel">
<div class="mock-card">
<div class="mock-top">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="mock-title">Visão Geral</span>
</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 class="col-lg-6 fade-in-up" style="animation-delay: 0.2s;">
<div class="mockup-perspective">
<div class="mockup-card glass-card">
<div class="mock-header">
<div class="dots"><span></span><span></span><span></span></div>
<div class="bar">LineGestão / Dashboard</div>
</div>
<div class="mock-kpi">
<span class="kpi-label">Contratos</span>
<span class="kpi-value">12</span>
<span class="kpi-tag"><i class="bi bi-file-earmark-text"></i> organizado</span>
</div>
<div class="mock-kpi">
<span class="kpi-label">Clientes</span>
<span class="kpi-value">34</span>
<span class="kpi-tag"><i class="bi bi-people"></i> centralizado</span>
</div>
<div class="mock-line">
<div class="line-icon"><i class="bi bi-sim"></i></div>
<div class="line-info">
<div class="line-title">Linha 55XX9XXXXXXXX</div>
<div class="line-sub">Status: Ativa • Operadora: Vivo</div>
<div class="mock-body">
<div class="kpi-row">
<div class="kpi-box">
<small>Linhas Ativas</small>
<strong>128</strong>
</div>
<div class="kpi-box">
<small>Fatura Atual</small>
<strong>R$ 4.2k</strong>
</div>
</div>
<div class="fake-chart">
<div class="bar" style="height: 40%"></div>
<div class="bar" style="height: 60%"></div>
<div class="bar active" style="height: 85%"></div>
<div class="bar" style="height: 55%"></div>
</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 class="line-pill">OK</div>
</div>
</div>
</div>
</div>
</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 class="floating-badge">
<i class="bi bi-check-circle-fill"></i> Controle Total
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" class="features-section">
<div class="container my-5">
<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>
<section id="features" class="features-section">
<div class="container">
<div class="text-center mb-5 fade-in-up">
<h2 class="section-title">Tudo em um só lugar</h2>
<p class="section-subtitle">O essencial para eliminar erros manuais e ganhar tempo.</p>
</div>
<div class="col-auto mb-4 feature-item" data-animate>
<app-feature-card
title="Gerenciamento de Clientes"
[textAlign]="'center'"
iconClass="bi bi-people"
description="<strong>Organize e acompanhe seus clientes</strong> com praticidade e segurança, garantindo uma gestão eficiente."
></app-feature-card>
</div>
<div class="grid-features fade-in-up">
<div class="feature-box">
<div class="icon-sq"><i class="bi bi-laptop"></i></div>
<h3>Monitoramento</h3>
<p>Visão estratégica de contratos, valores e consumo em tempo real.</p>
</div>
<div class="col-auto mb-4 feature-item" data-animate>
<app-feature-card
title="Importação via Excel"
[textAlign]="'center'"
iconClass="bi bi-table"
description="<strong>Integre dados rapidamente</strong> sem esforço manual, substituindo planilhas por uma solução moderna e automatizada."
></app-feature-card>
</div>
<div class="feature-box">
<div class="icon-sq"><i class="bi bi-file-earmark-spreadsheet"></i></div>
<h3>Importação Excel</h3>
<p>Traga seus dados legados em segundos, substituindo planilhas manuais.</p>
</div>
</div>
<div class="value-strip" data-animate>
<div class="value">
<i class="bi bi-check2-circle"></i>
<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 class="feature-box">
<div class="icon-sq"><i class="bi bi-people"></i></div>
<h3>Gestão de Usuários</h3>
<p>Vincule linhas a colaboradores, organize centros de custo e evite desperdícios.</p>
</div>
</div>
</div>
</section>
<div class="row justify-content-center button-section" data-animate>
<div class="col-auto">
<app-cta-button
label="COMEÇAR AGORA"
(clicked)="iniciar()">
</app-cta-button>
</div>
</div>
</div>
</section>
<div style="height: 80px;"></div>
</div>
</section>

View File

@ -1,544 +1,451 @@
: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;
--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-page {
.home-wrapper {
position: relative;
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;
}
/* O Mesh Overlay dá a textura geral */
.mesh-overlay {
position: absolute;
inset: 0;
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);
}
radial-gradient(circle at 15% 10%, var(--brand-soft) 0%, transparent 40%),
radial-gradient(circle at 85% 30%, rgba(3, 15, 170, 0.08) 0%, transparent 40%),
linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.8) 100%);
}
/* ✅ BLOBS FIXOS (continuam no scroll) */
.page-blob {
position: fixed;
pointer-events: none;
border-radius: 999px;
filter: blur(34px);
opacity: 0.55;
z-index: 0;
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;
}
/* Blobs flutuantes e suaves */
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
animation: floatBlob 10s ease-in-out infinite;
}
/* ✅ garante que o conteúdo fique acima do fundo */
.hero,
.features-section,
.container {
.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;
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 {
padding: 56px 0 18px 0;
/* ========================================= */
/* HERO SECTION */
/* ========================================= */
.hero-container {
margin-bottom: 60px;
}
.hero-inner {
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 {
.badge-saas {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(227, 61, 207, 0.22);
backdrop-filter: blur(10px);
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 14px;
gap: 8px;
background: white;
border: 1px solid rgba(227, 61, 207, 0.2);
color: var(--brand-primary);
padding: 6px 14px;
border-radius: 99px;
font-size: 12px;
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 {
width: 100%;
margin-top: 12px;
}
/* título */
.main-title {
.display-title {
font-family: 'Inter', sans-serif;
font-size: 52px;
line-height: 1.05;
margin: 18px 0 18px 0;
font-size: 3.5rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: -1.5px;
color: var(--text-main);
margin-bottom: 24px;
display: flex;
flex-direction: column;
align-items: center;
@media(max-width: 992px) { font-size: 2.8rem; }
@media(max-width: 576px) { font-size: 2.2rem; }
@media (max-width: 1400px) { font-size: 44px; }
@media (max-width: 1024px) { font-size: 38px; }
@media (max-width: 768px) { font-size: 32px; }
@media (max-width: 480px) { font-size: 28px; }
.text-gradient {
background: linear-gradient(90deg, var(--brand-primary) 0%, #030FAA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
.main-title .first-line,
.main-title .second-line {
font-weight: 650;
display: block;
color: var(--text);
text-align: center;
.lead-text {
font-family: 'Inter', sans-serif;
font-size: 1.125rem;
color: var(--text-muted);
line-height: 1.6;
max-width: 520px;
margin-bottom: 36px;
strong { color: var(--text-main); }
}
.main-title strong {
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 */
/* Botões */
.hero-actions {
display: flex;
gap: 14px;
align-items: center;
justify-content: center;
margin-top: 16px;
gap: 16px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.cta-secondary {
height: 44px;
padding: 0 14px;
.btn-primary-saas {
background: var(--brand-primary);
color: white;
border: none;
padding: 14px 32px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(17, 18, 20, 0.10);
color: var(--text);
font-weight: 800;
display: inline-flex;
align-items: center;
gap: 10px;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
i { color: var(--brand); }
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 25px -5px rgba(227, 61, 207, 0.3);
&:hover {
transform: translateY(-2px);
border-color: rgba(227, 61, 207, 0.28);
box-shadow: 0 12px 24px rgba(17, 18, 20, 0.10);
background: #d42bbf;
box-shadow: 0 15px 30px -5px rgba(227, 61, 207, 0.4);
}
}
/* =============================== */
/* MOCK (direita) */
/* =============================== */
.hero-mock {
display: flex;
justify-content: flex-end;
.btn-secondary-saas {
background: white;
color: var(--text-main);
border: 1px solid rgba(0,0,0,0.1);
padding: 14px 24px;
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;
}
}
.mock-card {
width: min(460px, 100%);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(227, 61, 207, 0.14);
backdrop-filter: blur(12px);
box-shadow: 0 22px 46px rgba(17, 18, 20, 0.10);
overflow: hidden;
.mockup-card {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 24px;
box-shadow:
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 {
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;
gap: 8px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(227, 61, 207, 0.10), rgba(255, 255, 255, 0.20));
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0,0,0,0.05);
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(17, 18, 20, 0.12);
.dots span {
display: inline-block;
width: 10px; height: 10px;
background: #E2E8F0; border-radius: 50%;
margin-right: 6px;
}
.mock-title {
margin-left: 6px;
font-weight: 950;
font-family: 'Inter', sans-serif;
color: var(--text);
.bar {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.mock-grid {
padding: 16px;
display: grid;
grid-template-columns: 1fr 1fr;
.kpi-row {
display: flex;
gap: 12px;
@media (max-width: 380px) { grid-template-columns: 1fr; }
}
.mock-kpi {
border-radius: var(--radius-lg);
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 {
padding: 18px 0 60px 0;
background: transparent;
}
.section-head {
text-align: center;
margin-bottom: 24px;
}
.section-title {
font-family: 'Inter', sans-serif;
font-weight: 950;
color: var(--text);
font-size: 30px;
.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);
@media (max-width: 768px) { font-size: 24px; }
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;
}
}
.section-title .brand { color: var(--brand); }
.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;
align-items: center;
gap: 8px;
animation: floatBadge 5s ease-in-out infinite;
z-index: 10;
@media(max-width: 576px) { left: 0; bottom: -20px; }
i { color: #10B981; font-size: 18px; }
}
@keyframes floatBadge {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
/* ========================================= */
/* FEATURES SECTION */
/* ========================================= */
.features-section {
padding: 80px 0;
position: relative;
}
.section-title {
font-size: 32px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 10px;
font-family: 'Inter', sans-serif;
}
.section-subtitle {
margin-top: 10px;
color: var(--muted);
font-family: 'Poppins', sans-serif;
font-size: 16px;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto;
}
/* ✅ AQUI: 3 CARDS CENTRALIZADOS LADO A LADO NO NOTEBOOK */
.feature-cards-row {
display: flex;
justify-content: center;
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 {
.grid-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 32px;
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;
justify-content: center;
gap: 18px;
flex-wrap: wrap;
.feature-box {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(10px); /* Glassmorphism nos cards também */
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 {
display: inline-flex;
align-items: center;
gap: 10px;
&:hover {
transform: translateY(-8px);
background: white;
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; }
span { color: var(--text); font-family: 'Inter', sans-serif; }
.icon-sq {
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;
}
}
/* =============================== */
/* ✅ ANIMAÇÕES SSR-SAFE */
/* =============================== */
[data-animate] { opacity: 1; transform: none; }
.js-animate [data-animate] {
/* Utilitário de Animação */
.fade-in-up {
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
transform: translateY(14px);
transition: opacity 600ms ease, transform 600ms ease;
will-change: opacity, transform;
transform: translateY(30px);
}
.js-animate [data-animate].is-visible {
opacity: 1;
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; }
@keyframes fadeInUp {
to { opacity: 1; transform: translateY(0); }
}

View File

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

View File

@ -1,73 +1,126 @@
<div class="login-wrapper">
<div class="login-card shadow-sm">
<!-- Título -->
<div class="login-title mb-4">
<h2 class="mb-0">Login</h2>
</div>
<div class="login-left">
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<!-- 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 class="left-content fade-in-up">
<div class="brand-header mb-4">
<div class="brand-logo">
<i class="bi bi-layers-fill"></i>
<span>LineGestão</span>
</div>
</div>
<!-- Senha -->
<div class="mb-4">
<label class="form-label">Senha</label>
<input
type="password"
class="form-control"
formControlName="password"
placeholder="Senha" />
<h1 class="welcome-title">Acesse sua conta</h1>
<p class="welcome-subtitle">Informe suas credenciais para entrar na plataforma.</p>
<div class="text-danger small mt-1" *ngIf="hasError('password')">
A senha deve ter pelo menos 6 caracteres.
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
<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>
<!-- 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>

View File

@ -1,216 +1,530 @@
/* ========================= */
/* TELA DE LOGIN */
/* ========================= */
:host {
/* 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 {
min-height: calc(100vh - 69.2px);
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%;
min-height: 360px; /* antes 380px */
padding: 26px 22px; /* antes 28px 24px */
box-sizing: border-box;
height: 100vh; /* Ocupa 100% da altura da viewport */
min-height: 600px; /* Evita esmagamento em telas muito baixas */
background: #fff;
font-family: 'Inter', sans-serif;
overflow: hidden; /* Garante que nada gere scroll indesejado na tela principal */
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
.mb-3,
.mb-4 {
margin-bottom: 0.8rem; /* antes 0.9rem */
/* Responsivo: Vira coluna no mobile/tablet vertical */
@media (max-width: 992px) {
flex-direction: column;
height: auto;
min-height: 100vh;
overflow-y: auto; /* Permite scroll no mobile */
}
}
/* NOTEB00KS (≤1440px) deixa ainda mais compacto */
@media (max-width: 1440px) {
.login-card {
max-width: 430px;
min-height: 330px;
padding: 22px 20px;
}
/* ================================================= */
/* LADO ESQUERDO (FORMULÁRIO) */
/* ================================================= */
.login-left {
flex: 1; /* Ocupa o espaço necessário */
display: flex;
flex-direction: column;
justify-content: center; /* Centraliza verticalmente */
padding: 0 60px;
background: white;
position: relative;
z-index: 5;
.login-title h2 {
font-size: 30px; /* levemente menor */
/* Ajuste Notebook: Menos padding lateral */
@media (max-width: 1366px) { padding: 0 40px; }
/* Ajuste Mobile */
@media (max-width: 576px) { padding: 32px 24px; }
}
/* 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; }
}
}
/* notebooks / tablets (≤992px) */
@media (max-width: 992px) {
.login-card {
max-width: 400px;
min-height: 310px;
padding: 20px 18px;
}
/* Títulos */
.welcome-title {
font-size: 32px;
font-weight: 700;
color: var(--text-main);
margin-bottom: 8px;
letter-spacing: -0.5px;
line-height: 1.2;
.login-title h2 {
font-size: 26px;
}
@media (max-width: 1366px) { font-size: 28px; }
}
.form-control {
height: 36px;
.welcome-subtitle {
color: var(--text-muted);
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;
}
.login-btn-submit {
font-size: 13px;
padding: 7px 0;
}
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;
.mb-3,
.mb-4 {
margin-bottom: 0.7rem;
/* 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;
}
}
}
/* celulares (≤576px) bem enxuto */
@media (max-width: 576px) {
.login-card {
max-width: 340px;
min-height: auto;
padding: 18px 14px;
}
/* Wrapper para input de senha com ícone */
.input-wrapper {
position: relative;
.login-title h2 {
font-size: 24px;
}
input { padding-right: 40px; }
.form-control {
height: 34px;
font-size: 13px;
}
.toggle-pass {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 16px;
.login-btn-submit {
font-size: 12.5px;
padding: 7px 0;
&:hover { color: var(--text-main); }
}
}
.error-msg {
color: #ef4444;
font-size: 11px;
margin-top: 4px;
font-weight: 500;
}
/* ========================= */
/* TIPOGRAFIA E FORM */
/* ========================= */
/* AÇÕES (Checkbox + Esqueci Senha) */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
font-size: 13px;
}
/* Título centralizado rosa */
.login-title {
.custom-checkbox {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem !important;
gap: 8px;
cursor: pointer;
color: var(--text-muted);
user-select: none;
h2 {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 700;
font-size: 32px;
color: #c91eb5;
margin: 0;
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);
}
}
}
/* Labels mesmo tamanho do cadastro */
.form-label {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 500;
font-size: 14px;
color: #000000;
}
/* Inputs iguais aos do cadastro (borda azul) */
.form-control {
height: 38px;
border-radius: 8px;
border: 2px solid #6066ff;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 500;
font-size: 14px;
color: #000000;
&::placeholder {
color: rgba(0, 0, 0, 0.5);
}
}
/* Botão ENTRAR rosa sólido, mesmo estilo do cadastrar */
.login-btn-submit {
border-radius: 40px;
border: none;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
.forgot-link {
color: var(--brand-blue);
text-decoration: none;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 9px 0;
background-color: #c91eb5;
color: #ffffff;
font-size: 13px;
&:hover {
filter: brightness(1.04);
&: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;
}
}
/* Mensagens de erro */
.text-danger.small {
/* 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;
background: #fef2f2;
color: #ef4444;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #fecaca;
}
/* ================================================= */
/* 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;
}
}
/* Barra do Topo (Mock) */
.mock-top-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
.dots span {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
background: #cbd5e1;
margin-right: 6px;
}
.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%;
}
}
/* Grid Interno (Mock) */
.mock-grid {
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;
apiError = '';
// Variável necessária para o ícone de olho no HTML novo
showPassword = false;
toastMessage = '';
@ViewChild('successToast') successToast!: ElementRef;
@ -26,11 +29,18 @@ export class LoginComponent {
@Inject(PLATFORM_ID) private platformId: object
) {
this.loginForm = this.fb.group({
username: ['', [Validators.required]], // aqui é email
password: ['', [Validators.required, Validators.minLength(6)]]
username: ['', [Validators.required]],
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) {
this.toastMessage = message;
@ -65,49 +75,66 @@ export class LoginComponent {
// evita token antigo conflitar
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);
}
onSubmit(): void {
console.log('🚀 Iniciando login...');
this.apiError = '';
if (this.loginForm.invalid) {
console.log('❌ Formulário inválido', this.loginForm.errors);
this.loginForm.markAllAsTouched();
return;
}
this.isSubmitting = true;
const v = this.loginForm.value;
const payload = {
email: v.username,
password: v.password
};
this.authService.login(payload).subscribe({
next: async (res) => {
this.authService.login({ email: v.username, password: v.password }).subscribe({
next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
console.log('✅ Resposta da API:', res);
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) {
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;
}
// ✅ salva token para o Interceptor anexar nas próximas requisições
console.log('🔑 Token encontrado. Salvando...');
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);
// ✅ Vai para /geral já levando a mensagem do toast
this.router.navigate(['/geral'], {
state: { toastMessage: `Bem-vindo, ${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?');
});
} catch (e) {
console.error('❌ Erro ao processar token ou navegar:', e);
// Força a ida mesmo se o nome falhar
this.router.navigate(['/geral']);
}
},
error: (err) => {
console.error('❌ Erro na requisição:', err);
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);
if (!control) return false;
if (error) return control.touched && control.hasError(error);
return control.touched && control.invalid;
return !!(control.touched && control.invalid);
}
}

View File

@ -24,13 +24,15 @@
<div class="title-badge" data-animate>
<i class="bi bi-table"></i> MUREG
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">MUREG</h5>
<small class="subtitle">Gestão de registros MUREG</small>
</div>
<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">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg
<i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button>
</div>
</div>
@ -39,70 +41,91 @@
<div class="kpi">
<span class="lbl">Clientes</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ total || 0 }}</span>
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ total || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Registros</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span>
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Trocas</span>
<span class="val text-brand">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span>
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-success">ICCID</span>
<span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span>
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span>
</span>
</div>
</div>
<div class="controls mt-3 mb-2" data-animate>
<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()" />
<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 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">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<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>
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="pageSize"
(change)="onPageSizeChange()"
[disabled]="loading"
>
<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 class="mureg-body">
<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">
Nenhum dado encontrado.
Nenhum dado encontrado.
</div>
<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-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.total }} Registros</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>
</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 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">
<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>
</div>
<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>
<span class="chip-muted">
<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">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA ANTIGA</th>
<th>LINHA NOVA</th>
<th>ICCID</th>
<th>DATA MUREG</th>
<th>SITUAÇÃO</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngIf="groupRows.length === 0">
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro.</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item || '-' }}</td>
<td class="text-dark">{{ r.linhaAntiga || '-' }}</td>
<td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
<td class="small font-monospace">{{ r.iccid || '-' }}</td>
<td class="text-muted small fw-bold">{{ displayValue('dataDaMureg', r.dataDaMureg) }}</td>
<td>
<span class="status-pill" [class.is-swap]="isTroca(r)" [class.is-same]="!isTroca(r)">
{{ isTroca(r) ? 'TROCA' : 'SEM TROCA' }}
</span>
</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro"><i class="bi bi-pencil-square"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA ANTIGA</th>
<th>LINHA NOVA</th>
<th>ICCID</th>
<th>DATA MUREG</th>
<th>SITUAÇÃO</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngIf="groupRows.length === 0">
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">
Nenhum registro.
</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item || '-' }}</td>
<td class="text-dark">{{ r.linhaAntiga || '-' }}</td>
<td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
<td class="small font-monospace">{{ r.iccid || '-' }}</td>
<td class="text-muted small fw-bold">{{ displayValue('dataDaMureg', r.dataDaMureg) }}</td>
<td>
<span class="status-pill" [class.is-swap]="isTroca(r)" [class.is-same]="!isTroca(r)">
{{ isTroca(r) ? 'TROCA' : 'SEM TROCA' }}
</span>
</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
<i class="bi bi-pencil-square"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mureg-footer">
<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>
<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>
<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>
<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>
@ -178,115 +225,245 @@
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<!-- ============================== -->
<!-- EDIT MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Registro Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</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">
<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 class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Registro Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</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>
<!-- ============================== -->
<!-- CREATE MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</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">
<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 class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</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';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
HttpClient,
HttpClientModule,
HttpParams
} from '@angular/common/http';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { LinesService } from '../../services/lines.service';
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> {
page?: number;
pageSize?: number;
@ -43,6 +29,48 @@ interface ClientGroup {
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({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
@ -58,7 +86,8 @@ export class Mureg implements AfterViewInit {
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private linesService: LinesService
) {}
private readonly apiBase = 'https://localhost:7205/api/mureg';
@ -68,7 +97,6 @@ export class Mureg implements AfterViewInit {
pagedClientGroups: ClientGroup[] = [];
expandedGroup: string | null = null;
groupRows: MuregRow[] = [];
private rowsByClient = new Map<string, MuregRow[]>();
// KPIs
@ -83,6 +111,19 @@ export class Mureg implements AfterViewInit {
pageSize = 10;
total = 0;
// ====== OPTIONS (GERAL) ======
clientOptions: string[] = [];
// create options
lineOptionsCreate: LineOptionDto[] = [];
createClientsLoading = false;
createLinesLoading = false;
// edit options
lineOptionsEdit: LineOptionDto[] = [];
editClientsLoading = false;
editLinesLoading = false;
// ====== EDIT MODAL ======
editOpen = false;
editSaving = false;
@ -92,18 +133,23 @@ export class Mureg implements AfterViewInit {
createOpen = false;
createSaving = false;
createModel: any = {
cliente: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: ''
selectedClient: '',
mobileLineId: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: '',
clienteInfo: ''
};
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => { this.refresh(); });
setTimeout(() => {
this.preloadClients(); // ✅ já deixa o select pronto
this.refresh();
});
}
private initAnimations() {
@ -148,6 +194,7 @@ export class Mureg implements AfterViewInit {
}
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get pageNumbers() {
const total = this.totalPages;
const current = this.page;
@ -159,7 +206,9 @@ export class Mureg implements AfterViewInit {
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageEnd() {
if (this.total === 0) return 0;
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; }
// =======================================================================
// LOAD LOGIC
// LOAD LOGIC (lista e grupos)
// =======================================================================
private loadForGroups() {
this.loading = true;
@ -218,8 +267,10 @@ export class Mureg implements AfterViewInit {
const trocas = arr.filter(x => this.isTroca(x)).length;
const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length;
const semIccid = total - comIccid;
trocasTotal += trocas;
iccidsTotal += comIccid;
groups.push({ cliente, total, trocas, comIccid, semIccid });
});
@ -236,6 +287,7 @@ export class Mureg implements AfterViewInit {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
this.pagedClientGroups = this.clientGroups.slice(start, end);
if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) {
this.expandedGroup = null;
this.groupRows = [];
@ -248,6 +300,7 @@ export class Mureg implements AfterViewInit {
this.groupRows = [];
return;
}
this.expandedGroup = cliente;
const rows = this.rowsByClient.get(cliente) ?? [];
this.groupRows = [...rows].sort((a, b) => {
@ -279,6 +332,7 @@ export class Mureg implements AfterViewInit {
const iccid = pick(x, ['iccid', 'ICCID']);
const dataDaMureg = pick(x, ['dataDaMureg', 'data_da_mureg', 'DATA DA MUREG']);
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}`);
return {
@ -289,131 +343,352 @@ export class Mureg implements AfterViewInit {
iccid: String(iccid ?? ''),
dataDaMureg: String(dataDaMureg ?? ''),
cliente: String(cliente ?? ''),
mobileLineId,
raw: x
};
}
// ====== MODAL EDIÇÃO ======
// =======================================================================
// CLIENTS / LINES OPTIONS (GERAL)
// =======================================================================
private preloadClients() {
if (this.clientOptions.length > 0) return;
// 1. Abrir modal
onEditar(r: MuregRow) {
this.editOpen = true;
this.editSaving = false;
this.createClientsLoading = true;
this.editClientsLoading = true;
this.editModel = {
id: r.id,
item: r.item,
linhaAntiga: r.linhaAntiga,
linhaNova: r.linhaNova,
iccid: r.iccid,
cliente: r.cliente,
dataDaMureg: this.isoToDateInput(r.dataDaMureg)
};
this.linesService.getClients().subscribe({
next: (list) => {
this.clientOptions = (list ?? []).filter(x => !!String(x ?? '').trim());
this.createClientsLoading = false;
this.editClientsLoading = false;
this.cdr.detectChanges();
},
error: async () => {
this.createClientsLoading = false;
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() {
this.editOpen = false;
this.editModel = null;
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() {
if(!this.editModel || !this.editModel.id) return;
this.editSaving = true;
if (!this.editModel || !this.editModel.id) return;
const payload = {
...this.editModel,
dataDaMureg: this.dateInputToIso(this.editModel.dataDaMureg)
};
const mobileLineId = String(this.editModel.mobileLineId ?? '').trim();
if (!mobileLineId) {
this.showToast('Selecione Cliente e Linha Antiga (GERAL).');
return;
}
this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({
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.');
}
});
}
this.editSaving = true;
// ====== 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() {
this.createOpen = true;
this.createSaving = false;
this.createModel = {
cliente: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: ''
};
}
if (payload.item == null) delete payload.item;
closeCreate() {
this.createOpen = false;
}
this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({
next: async () => {
this.editSaving = false;
await this.showToast('Registro atualizado com sucesso!');
const currentGroup = this.expandedGroup;
this.closeEdit();
this.loadForGroups();
saveCreate() {
if(!this.createModel.cliente || !this.createModel.linhaNova) {
this.showToast('Preencha Cliente e Linha Nova.');
return;
if (currentGroup) {
setTimeout(() => {
this.expandedGroup = currentGroup;
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)
};
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 () => {
this.createSaving = false;
await this.showToast('Erro ao criar Mureg.');
}
});
});
}
// =======================================================================
// Helpers
// =======================================================================
private toIntOrZero(val: any): number {
const n = parseInt(String(val ?? '').trim(), 10);
return Number.isFinite(n) ? n : 0;
}
private toIntOrNull(val: any): number | null {
const s = String(val ?? '').trim();
if (!s) return null;
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
// Helpers de Data
private isoToDateInput(iso: string | null | undefined): string {
if(!iso) return '';
const dt = new Date(iso);
if(Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0,10);
if (!iso) return '';
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0, 10);
}
private dateInputToIso(val: string | null | undefined): string | null {
if(!val) return null;
const dt = new Date(val);
if(Number.isNaN(dt.getTime())) return null;
return dt.toISOString();
if (!val) return null;
const dt = new Date(val);
if (Number.isNaN(dt.getTime())) return null;
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 {
if (v === null || v === undefined || String(v).trim() === '') return '-';
if (key === 'dataDaMureg') {
const s = String(v).trim();
const d = new Date(s);
if (!Number.isNaN(d.getTime())) {
return new Intl.DateTimeFormat('pt-BR').format(d);
}
if (!Number.isNaN(d.getTime())) return new Intl.DateTimeFormat('pt-BR').format(d);
return s;
}
return String(v);
}
@ -422,9 +697,13 @@ export class Mureg implements AfterViewInit {
this.toastMessage = message;
this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return;
try {
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();
} catch (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>
<!-- CREATE MODAL -->
<!-- CREATE MODAL (✅ BEBENDO DO GERAL) -->
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
@ -306,6 +306,7 @@
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<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" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" />
<!-- ✅ Cliente (GERAL) -->
<div class="form-field span-2">
<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>
<!-- ✅ 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">
<label>Linha Nova</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</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" />
<label>ICCID (auto)</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" readonly />
</div>
<div class="form-field span-2">
@ -340,6 +382,7 @@
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea>
</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-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 {
display: flex;
@ -603,7 +609,10 @@
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 {
@ -612,3 +621,18 @@
&: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;
}
/** ✅ 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({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
@ -60,6 +71,9 @@ export class TrocaNumero implements AfterViewInit {
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 ======
groups: GroupItem[] = [];
pagedGroups: GroupItem[] = [];
@ -98,6 +112,14 @@ export class TrocaNumero implements AfterViewInit {
observacao: ''
};
/** ✅ selects do GERAL no modal */
clientsFromGeral: string[] = [];
linesFromClient: LineOptionDto[] = [];
selectedCliente: string = '';
selectedLineId: string = '';
loadingClients = false;
loadingLines = false;
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
@ -285,7 +307,7 @@ export class TrocaNumero implements AfterViewInit {
const iccid = pick(x, ['iccid', 'ICCID']);
const dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']);
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}`);
@ -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 ======
onEditar(r: TrocaRow) {
this.editOpen = true;
@ -360,6 +455,7 @@ export class TrocaNumero implements AfterViewInit {
this.createOpen = true;
this.createSaving = false;
// reset do form
this.createModel = {
item: '',
linhaAntiga: '',
@ -369,6 +465,15 @@ export class TrocaNumero implements AfterViewInit {
motivo: '',
observacao: ''
};
// reset dos selects
this.selectedCliente = '';
this.selectedLineId = '';
this.clientsFromGeral = [];
this.linesFromClient = [];
// carrega clientes do GERAL
this.loadClientsFromGeral();
}
closeCreate() {
@ -376,13 +481,27 @@ export class TrocaNumero implements AfterViewInit {
}
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;
const payload = {
item: this.toNumberOrNull(this.createModel.item),
linhaAntiga: this.createModel.linhaAntiga,
linhaAntiga: this.createModel.linhaAntiga, // auto do GERAL
linhaNova: this.createModel.linhaNova,
iccid: this.createModel.iccid,
iccid: this.createModel.iccid, // auto do GERAL
motivo: this.createModel.motivo,
observacao: this.createModel.observacao,
dataTroca: this.dateInputToIso(this.createModel.dataTroca)

View File

@ -49,6 +49,11 @@ export interface MobileLineDetail extends MobileLineList {
dataEntregaCliente?: string | null;
}
export interface LineOption {
id: string;
linha: string;
}
@Injectable({ providedIn: 'root' })
export class LinesService {
// ✅ 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);
}
// (opcional) se você usa groups/clients no Geral, pode manter aqui também:
getClients(skil?: string): Observable<string[]> {
let params = new HttpParams();
const s = (skil ?? '').trim();
@ -106,4 +110,11 @@ export class LinesService {
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 { appConfig } from './app/app.config';
import { App } from './app/app';
import { AppComponent } from './app/app';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
bootstrapApplication(App, appConfig)
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -1,5 +1,105 @@
@import "bootstrap-icons/font/bootstrap-icons.css";
body {
background: #EFEFEF !important;
/* Variáveis baseadas na sua marca, mas modernizadas */
: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;
}