Minha alteração

This commit is contained in:
Eduardo 2026-02-09 16:30:43 -03:00
parent a8e40b640d
commit 49cdaefddf
84 changed files with 16546 additions and 1157 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
@Injectable()
export class AppTitleStrategy extends TitleStrategy {
private readonly appName = 'LineGestão';
constructor(private readonly titleService: Title) {
super();
}
override updateTitle(routerState: RouterStateSnapshot): void {
const pageTitle = this.buildTitle(routerState);
this.titleService.setTitle(
pageTitle ? `${pageTitle} - ${this.appName}` : this.appName
);
}
}

View File

@ -1,26 +1,31 @@
import { import {
ApplicationConfig, ApplicationConfig,
LOCALE_ID,
provideBrowserGlobalErrorListeners, provideBrowserGlobalErrorListeners,
provideZoneChangeDetection provideZoneChangeDetection
} from '@angular/core'; } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter, TitleStrategy } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor'; import { authInterceptor } from './interceptors/auth.interceptor';
import { sessionInterceptor } from './interceptors/session.interceptor';
import { AppTitleStrategy } from './app-title.strategy';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
{ provide: LOCALE_ID, useValue: 'pt-BR' },
provideRouter(routes), provideRouter(routes),
{ provide: TitleStrategy, useClass: AppTitleStrategy },
provideClientHydration(withEventReplay()), provideClientHydration(withEventReplay()),
// ✅ HttpClient com fetch + interceptor // ✅ HttpClient com fetch + interceptor
provideHttpClient( provideHttpClient(
withFetch(), withFetch(),
withInterceptors([authInterceptor]) withInterceptors([authInterceptor, sessionInterceptor])
), ),
] ]
}; };

View File

@ -8,6 +8,7 @@ import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento'; import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard'; import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia'; import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero'; import { TrocaNumero } from './pages/troca-numero/troca-numero';
@ -15,24 +16,32 @@ import { Dashboard } from './pages/dashboard/dashboard';
import { Notificacoes } from './pages/notificacoes/notificacoes'; import { Notificacoes } from './pages/notificacoes/notificacoes';
import { NovoUsuario } from './pages/novo-usuario/novo-usuario'; import { NovoUsuario } from './pages/novo-usuario/novo-usuario';
import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos'; import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos';
import { Resumo } from './pages/resumo/resumo';
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
import { Historico } from './pages/historico/historico';
import { Perfil } from './pages/perfil/perfil';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
{ path: 'register', component: Register }, { path: 'register', component: Register, title: 'Cadastro' },
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent, title: 'Login' },
{ path: 'geral', component: Geral, canActivate: [authGuard] }, { path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ path: 'mureg', component: Mureg, canActivate: [authGuard] }, { path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] }, { path: 'faturamento', component: Faturamento, canActivate: [authGuard], title: 'Faturamento' },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
{ path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard] }, { path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard], title: 'Novo Usuário' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard] }, { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' },
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
// ✅ rota correta // ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, { path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
// ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard // ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard
{ path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' },

View File

@ -1,10 +1,11 @@
// src/app/app.ts // src/app/app.ts
import { Component, Inject, PLATFORM_ID } from '@angular/core'; import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { Router, NavigationEnd, RouterOutlet } from '@angular/router'; import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Header } from './components/header/header'; import { Header } from './components/header/header';
import { FooterComponent } from './components/footer/footer'; import { FooterComponent } from './components/footer/footer';
import { AuthService } from './services/auth.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -36,10 +37,15 @@ export class AppComponent {
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard '/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
'/notificacoes', '/notificacoes',
'/chips-controle-recebidos', '/chips-controle-recebidos',
'/resumo',
'/parcelamentos',
'/historico',
'/perfil',
]; ];
constructor( constructor(
private router: Router, private router: Router,
private authService: AuthService,
@Inject(PLATFORM_ID) private platformId: object @Inject(PLATFORM_ID) private platformId: object
) { ) {
this.router.events.subscribe((event) => { this.router.events.subscribe((event) => {
@ -58,9 +64,30 @@ export class AppComponent {
// ✅ footer some ao logar + também no login/register // ✅ footer some ao logar + também no login/register
this.hideFooter = isLoggedRoute || this.isFullScreenPage; this.hideFooter = isLoggedRoute || this.isFullScreenPage;
// Em SSR não existe storage do navegador.
if (!isPlatformBrowser(this.platformId)) return;
if (isLoggedRoute && !this.hasValidSession()) {
this.router.navigateByUrl('/login');
}
} }
}); });
} }
private hasValidSession(): boolean {
const token = this.authService.token;
if (!token) return false;
const payload = this.authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
this.authService.logout();
return false;
}
return true;
}
} }
// ✅ SSR espera importar { App } de './app/app' // ✅ SSR espera importar { App } de './app/app'

View File

@ -3,24 +3,39 @@
width: 100%; width: 100%;
} }
:host(.form-control),
:host(.form-select),
:host(.select-glass) {
/* Reset Bootstrap field skin on host to avoid duplicate "field behind" effect. */
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
background-image: none !important;
box-shadow: none !important;
height: auto !important;
min-height: 0 !important;
}
.app-select { .app-select {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.app-select-trigger { .app-select-trigger {
position: relative;
width: 100%; width: 100%;
height: 42px; height: 42px;
border-radius: 10px; border-radius: 10px;
border: 1.5px solid rgba(15, 23, 42, 0.12); border: 1.5px solid rgba(15, 23, 42, 0.12);
padding: 0 36px 0 12px; padding: 0 28px 0 12px;
background: #fff; background: #fff;
color: #0f172a; color: #0f172a;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
@ -49,10 +64,12 @@
.app-select.sm .app-select-trigger { .app-select.sm .app-select-trigger {
height: 36px; height: 36px;
font-size: 13px; font-size: 13px;
padding-right: 32px; padding-right: 24px;
} }
.app-select-label { .app-select-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -60,6 +77,10 @@
.app-select-trigger i { .app-select-trigger i {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
} }

View File

@ -76,14 +76,27 @@
<div class="notif-content"> <div class="notif-content">
<div class="notif-header"> <div class="notif-header">
<span class="notif-title">{{ n.linha || 'Sem Linha' }}</span> <span class="notif-title-line">
<span class="notif-date">{{ n.referenciaData ? (n.referenciaData | date:'dd/MM') : '' }}</span> <span class="notif-line">{{ n.linha || 'Sem Linha' }}</span>
<span class="notif-sep"></span>
<span class="notif-client" [title]="n.cliente || '-'">{{ abbreviateName(n.cliente) }}</span>
</span>
</div> </div>
<p class="notif-desc"> <p class="notif-desc">
{{ n.tipo === 'Vencido' ? 'Venceu' : 'Vence em' }} - {{ n.cliente || 'Cliente não ident.' }} <span class="notif-verb">{{ getVigenciaLabel(n) }}:</span>
<strong class="notif-date-strong" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
{{ getVigenciaDate(n) }}
</strong>
</p> </p>
<div class="notif-meta" *ngIf="n.usuario"> <div class="notif-meta-lines">
<i class="bi bi-person"></i> {{ n.usuario }} <div class="notif-meta-line">
<span class="meta-label">Usuário:</span>
<span class="meta-value">{{ abbreviateName(n.usuario) }}</span>
</div>
<div class="notif-meta-line">
<span class="meta-label">Conta:</span>
<span class="meta-value">{{ n.conta || '-' }}</span>
</div>
</div> </div>
</div> </div>
@ -111,7 +124,7 @@
<div class="options-dropdown" *ngIf="optionsOpen"> <div class="options-dropdown" *ngIf="optionsOpen">
<div class="dropdown-arrow"></div> <div class="dropdown-arrow"></div>
<button type="button" class="options-item" (click)="closeOptions()"> <button type="button" class="options-item" (click)="goToProfile()">
<i class="bi bi-person-circle"></i> Perfil <i class="bi bi-person-circle"></i> Perfil
</button> </button>
<div class="divider"></div> <div class="divider"></div>
@ -413,6 +426,9 @@
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-grid-fill"></i> <span>Dashboard</span> <i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</a> </a>
<a routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-table"></i> <span>Resumo</span>
</a>
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim"></i> <span>Geral</span> <i class="bi bi-sim"></i> <span>Geral</span>
</a> </a>
@ -422,8 +438,14 @@
<a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-receipt"></i> <span>Faturamento</span> <i class="bi bi-receipt"></i> <span>Faturamento</span>
</a> </a>
<a routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
</a>
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-clock-history"></i> <span>Histórico</span>
</a>
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-people-fill"></i> <span>Dados de Usuários</span> <i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
</a> </a>
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span> <i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>

View File

@ -119,9 +119,20 @@ $border-color: #e5e7eb;
&.warn { background-color: #fef3c7; color: #d97706; } &.warn { background-color: #fef3c7; color: #d97706; }
} }
.notif-content { flex: 1; } .notif-content { flex: 1; }
.notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 2px; } .notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 4px; }
.notif-date { font-size: 11px; color: $text-muted; } .notif-title-line { font-weight: 700; color: $text-main; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.notif-desc { margin: 0; font-size: 12px; color: $text-muted; line-height: 1.3; } .notif-line { font-weight: 800; flex: 0 0 auto; }
.notif-sep { color: $text-muted; flex: 0 0 auto; }
.notif-client { font-weight: 600; color: $text-muted; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; }
.notif-desc { margin: 2px 0 6px; font-size: 12px; color: $text-muted; line-height: 1.3; display: flex; align-items: center; gap: 4px; }
.notif-verb { font-weight: 600; color: $text-muted; }
.notif-date-strong { font-weight: 800; color: $text-main; }
.notif-date-strong.warn { color: #d97706; }
.notif-date-strong.danger { color: #dc2626; }
.notif-meta-lines { display: flex; flex-direction: column; gap: 4px; }
.notif-meta-line { display: flex; gap: 6px; font-size: 12px; color: $text-muted; }
.meta-label { font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; }
.meta-value { font-weight: 600; color: $text-main; }
} }
/* MODAIS GERAIS */ /* MODAIS GERAIS */

View File

@ -70,6 +70,10 @@ export class Header {
'/notificacoes', '/notificacoes',
'/novo-usuario', '/novo-usuario',
'/chips-controle-recebidos', '/chips-controle-recebidos',
'/resumo',
'/parcelamentos',
'/historico',
'/perfil',
]; ];
constructor( constructor(
@ -122,7 +126,9 @@ export class Header {
} }
private syncHeaderState(rawUrl: string) { private syncHeaderState(rawUrl: string) {
const url = (rawUrl || '').split('?')[0].split('#')[0]; let url = (rawUrl || '').split('?')[0].split('#')[0];
if (url && !url.startsWith('/')) url = `/${url}`;
url = url.replace(/\/+$/, '');
this.isHome = (url === '/' || url === ''); this.isHome = (url === '/' || url === '');
@ -156,6 +162,11 @@ export class Header {
this.optionsOpen = false; this.optionsOpen = false;
} }
goToProfile() {
this.closeOptions();
this.router.navigate(['/perfil']);
}
openCreateUserModal() { openCreateUserModal() {
if (!this.isAdmin) return; if (!this.isAdmin) return;
this.createUserOpen = true; this.createUserOpen = true;
@ -203,6 +214,40 @@ export class Header {
}); });
} }
getVigenciaLabel(notification: NotificationDto): string {
return notification.tipo === 'Vencido' ? 'Venceu em' : 'Vence em';
}
getVigenciaDate(notification: NotificationDto): string {
const raw =
notification.dtTerminoFidelizacao ??
notification.referenciaData ??
notification.data;
if (!raw) return '-';
return new Date(raw).toLocaleDateString('pt-BR');
}
abbreviateName(value?: string | null): string {
const name = (value ?? '').trim();
if (!name) return '-';
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0];
const maxLen = 18;
const full = parts.join(' ');
if (full.length <= maxLen) return full;
if (parts.length >= 3) {
const candidate = `${parts[0]} ${parts[1]} ${parts[2][0]}.`;
if (candidate.length <= maxLen) return candidate;
return `${parts[0]} ${parts[1][0]}.`;
}
const two = `${parts[0]} ${parts[1]}`;
if (two.length <= maxLen) return two;
return `${parts[0]} ${parts[1][0]}.`;
}
get unreadCount() { get unreadCount() {
return this.notifications.filter(n => !n.lida).length; return this.notifications.filter(n => !n.lida).length;
} }

View File

@ -0,0 +1,27 @@
import { inject, PLATFORM_ID } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { AuthService } from '../services/auth.service';
export const adminGuard: CanActivateFn = () => {
const router = inject(Router);
const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
if (!isPlatformBrowser(platformId)) {
// Em SSR não há storage do usuário para validar sessão/perfil.
return true;
}
const token = authService.token;
if (!token) {
return router.parseUrl('/login');
}
const isAdmin = authService.hasRole('admin');
if (!isAdmin) {
return router.parseUrl('/dashboard');
}
return true;
};

View File

@ -10,10 +10,12 @@ export const authGuard: CanActivateFn = () => {
// SSR: não existe localStorage. Bloqueia e manda pro login. // SSR: não existe localStorage. Bloqueia e manda pro login.
if (!isPlatformBrowser(platformId)) { if (!isPlatformBrowser(platformId)) {
return router.parseUrl('/login'); // Em SSR não existe acesso ao storage do usuário.
// Deixa renderizar e valida no browser após hidratação.
return true;
} }
const token = localStorage.getItem('token'); const token = authService.token;
if (!token) { if (!token) {
return router.parseUrl('/login'); return router.parseUrl('/login');
@ -22,7 +24,7 @@ export const authGuard: CanActivateFn = () => {
const payload = authService.getTokenPayload(); const payload = authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId']; const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) { if (!tenantId) {
localStorage.removeItem('token'); authService.logout();
return router.parseUrl('/login'); return router.parseUrl('/login');
} }

View File

@ -1,10 +1,13 @@
import { HttpInterceptorFn } from '@angular/common/http'; import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => { export const authInterceptor: HttpInterceptorFn = (req, next) => {
// ✅ SSR-safe // ✅ SSR-safe
if (typeof window === 'undefined') return next(req); if (typeof window === 'undefined') return next(req);
const token = localStorage.getItem('token'); const authService = inject(AuthService);
const token = authService.token;
if (!token) return next(req); if (!token) return next(req);
return next( return next(

View File

@ -0,0 +1,25 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { catchError, throwError } from 'rxjs';
import { SessionNoticeService } from '../services/session-notice.service';
export const sessionInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
if (!isPlatformBrowser(platformId)) {
return next(req);
}
const sessionNotice = inject(SessionNoticeService);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err?.status === 401) {
sessionNotice.handleUnauthorized();
} else if (err?.status === 403) {
sessionNotice.handleForbidden();
}
return throwError(() => err);
})
);
};

View File

@ -34,7 +34,22 @@
<small class="subtitle">Importação e acompanhamento</small> <small class="subtitle">Importação e acompanhamento</small>
</div> </div>
<div class="header-actions"></div> <div class="header-actions d-flex gap-2 justify-content-end">
<button
*ngIf="isAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm"
(click)="openChipCreate()"
>
<i class="bi bi-plus-circle me-1"></i> Novo Chip
</button>
<button
*ngIf="isAdmin && activeTab === 'controle'"
class="btn btn-brand btn-sm"
(click)="openControleCreate()"
>
<i class="bi bi-plus-circle me-1"></i> Novo Recebimento
</button>
</div>
</div> </div>
<div class="tab-row"> <div class="tab-row">
@ -80,14 +95,14 @@
<input <input
*ngIf="activeTab === 'chips'" *ngIf="activeTab === 'chips'"
class="form-control" class="form-control"
placeholder="Pesquisar Chips..." placeholder="Pesquisar..."
[(ngModel)]="chipsSearch" [(ngModel)]="chipsSearch"
(ngModelChange)="onChipsSearch()" (ngModelChange)="onChipsSearch()"
/> />
<input <input
*ngIf="activeTab === 'controle'" *ngIf="activeTab === 'controle'"
class="form-control" class="form-control"
placeholder="Pesquisar Controle..." placeholder="Pesquisar..."
[(ngModel)]="controleSearch" [(ngModel)]="controleSearch"
(ngModelChange)="onControleSearch()" (ngModelChange)="onControleSearch()"
/> />
@ -169,7 +184,7 @@
<th>ITEM</th> <th>ITEM</th>
<th>NÚMERO DO CHIP</th> <th>NÚMERO DO CHIP</th>
<th>OBSERVAÇÕES</th> <th>OBSERVAÇÕES</th>
<th>AÇÕES</th> <th class="actions-col">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -182,6 +197,12 @@
<button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openChipEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openChipDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -258,7 +279,7 @@
<th>QTD.</th> <th>QTD.</th>
<th>CONTEÚDO DA NF</th> <th>CONTEÚDO DA NF</th>
<th>DATA DO RECEBIMENTO</th> <th>DATA DO RECEBIMENTO</th>
<th>AÇÕES</th> <th class="actions-col">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -274,6 +295,12 @@
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -295,7 +322,7 @@
<th>NÚMERO DA LINHA</th> <th>NÚMERO DA LINHA</th>
<th>VALOR UNIT.</th> <th>VALOR UNIT.</th>
<th>VALOR DA NF</th> <th>VALOR DA NF</th>
<th>AÇÕES</th> <th class="actions-col">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -312,6 +339,12 @@
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -351,7 +384,7 @@
</div> </div>
</section> </section>
<div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen" (click)="closeChipDetail(); closeControleDetail()"></div> <div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen || chipEditOpen || chipDeleteOpen || controleEditOpen || controleDeleteOpen || chipCreateOpen || controleCreateOpen" (click)="closeChipDetail(); closeControleDetail(); closeChipEdit(); cancelChipDelete(); closeControleEdit(); cancelControleDelete(); closeChipCreate(); closeControleCreate()"></div>
<!-- MODAL CHIP --> <!-- MODAL CHIP -->
<div class="modal-custom" *ngIf="chipDetailOpen"> <div class="modal-custom" *ngIf="chipDetailOpen">
@ -481,5 +514,242 @@
</div> </div>
</div> </div>
<!-- MODAL CHIP CREATE -->
<div class="modal-custom" *ngIf="chipCreateOpen">
<div class="modal-card modal-lg create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Informações do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipCreateModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="chipCreateSaving" (click)="saveChipCreate()">
{{ chipCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE CREATE -->
<div class="modal-custom" *ngIf="controleCreateOpen">
<div class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-card-list me-2"></i> Dados da Nota</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.notaFiscal" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.conteudoDaNf" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.serial" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.chip" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.numeroDaLinha" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-currency-exchange me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorUnit" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.quantidade" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Valor da NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorDaNf" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Data da NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateDataNf" (ngModelChange)="onControleCreateDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateRecebimento" /></div>
<div class="form-field"><label>Resumo</label><input class="form-check-input ms-2" type="checkbox" [(ngModel)]="controleCreateModel.isResumo" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="controleCreateSaving" (click)="saveControleCreate()">
{{ controleCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP EDIT -->
<div class="modal-custom" *ngIf="chipEditOpen">
<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 Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Identificação do Chip</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipEditModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="chipEditSaving" (click)="saveChipEdit()">
{{ chipEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP DELETE -->
<div class="modal-custom" *ngIf="chipDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Chip
</div>
<button class="btn btn-sm btn-icon" (click)="cancelChipDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o chip <strong>{{ chipDeleteTarget?.numeroDoChip }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelChipDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmChipDelete()">Excluir</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE EDIT -->
<div class="modal-custom" *ngIf="controleEditOpen">
<div class="modal-card modal-xl-custom" (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 Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-receipt-cutoff me-2"></i> Documento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.notaFiscal" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.chip" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.serial" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.conteudoDaNf" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.numeroDaLinha" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="controleEditModel.isResumo">
<option [ngValue]="false">DETALHE</option>
<option [ngValue]="true">RESUMO</option>
</select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-cash-coin me-2"></i> Valores e Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.quantidade" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorUnit" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorDaNf" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Data NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditDataNf" (ngModelChange)="onControleEditDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditRecebimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="controleEditSaving" (click)="saveControleEdit()">
{{ controleEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE DELETE -->
<div class="modal-custom" *ngIf="controleDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelControleDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover a NF <strong>{{ controleDeleteTarget?.notaFiscal }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelControleDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmControleDelete()">Excluir</button>
</div>
</div>
</div>

View File

@ -135,6 +135,7 @@
text-align: center; text-align: center;
.title-badge { justify-self: center; margin-bottom: 8px; } .title-badge { justify-self: center; margin-bottom: 8px; }
.header-actions { justify-self: center; }
} }
} }
@ -173,6 +174,22 @@
} }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; } .subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions {
justify-self: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
white-space: nowrap;
}
}
/* ========================================================== */ /* ========================================================== */
/* TABS E FILTROS */ /* TABS E FILTROS */
@ -213,9 +230,9 @@
/* Pesquisa */ /* Pesquisa */
.search-group { .search-group {
max-width: 300px; max-width: 270px;
border-radius: 12px; border-radius: 12px;
overflow-y: auto; overflow: hidden;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
background: #fff; background: #fff;
@ -261,6 +278,37 @@
&:hover { background: #fff; border-color: var(--blue); } &:hover { background: #fff; border-color: var(--blue); }
} }
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
filter: brightness(1.05);
}
}
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(3, 15, 170, 0.24);
color: var(--blue);
transition: all 0.2s ease;
&:hover {
background: #fff;
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
}
}
/* ========================================================== */ /* ========================================================== */
/* BODY (scroll interno igual Mureg) */ /* BODY (scroll interno igual Mureg) */
/* ========================================================== */ /* ========================================================== */
@ -412,6 +460,7 @@
.font-monospace { font-family: 'JetBrains Mono', monospace; letter-spacing: -0.5px; } .font-monospace { font-family: 'JetBrains Mono', monospace; letter-spacing: -0.5px; }
.td-clip { max-width: 260px; overflow-y: auto; text-overflow: ellipsis; } .td-clip { max-width: 260px; overflow-y: auto; text-overflow: ellipsis; }
.row-clickable { cursor: pointer; } .row-clickable { cursor: pointer; }
.actions-col { min-width: 152px; }
/* Paginação interna */ /* Paginação interna */
.table-pagination { .table-pagination {
@ -428,7 +477,14 @@
} }
/* Ações na tabela (estilo Mureg) */ /* Ações na tabela (estilo Mureg) */
.action-group { display: flex; justify-content: center; gap: 6px; } .action-group {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.action-group .btn-icon { .action-group .btn-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -443,6 +499,8 @@
cursor: pointer; cursor: pointer;
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); } &.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); }
&.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
} }
/* ========================================================== */ /* ========================================================== */
@ -485,7 +543,7 @@
/* ========================================================== */ /* ========================================================== */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow-y: auto; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } .modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; }
.modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; } .modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@ -495,13 +553,61 @@
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
.modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; background: rgba(3, 15, 170, 0.1); color: var(--blue);
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
&.success { background: var(--success-bg); color: var(--success-text); }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
} }
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } }
} }
.modal-body { padding: 20px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body { padding: 20px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
.modal-card.create-modal .edit-sections { gap: 14px; }
.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
.modal-card.create-modal .form-control,
.modal-card.create-modal .form-select { min-height: 40px; }
.modal-card.create-modal .form-check-input {
width: 1.05rem;
height: 1.05rem;
border-color: rgba(17, 18, 20, 0.32);
&:focus { box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); }
&:checked { background-color: var(--brand); border-color: var(--brand); }
}
.modal-card.create-modal .modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 14px 20px !important;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
}
.modal-card.create-modal .modal-footer .btn {
border-radius: 12px;
font-weight: 900;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
.modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
.modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
}
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } .details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow-y: auto; height: auto; display: flex; flex-direction: column; } div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow-y: auto; height: auto; display: flex; flex-direction: column; }
@ -517,3 +623,87 @@ div.box-body { padding: 16px; }
.val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; } .val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; }
} }
.edit-sections { display: grid; gap: 12px; }
.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
summary.box-header {
cursor: pointer;
user-select: none;
list-style: none;
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
&::-webkit-details-marker { display: none; }
}
.transition-icon { color: var(--muted); transition: transform 0.25s ease, color 0.25s ease; }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
&.span-2 { grid-column: span 2; }
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.64);
}
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.15);
background: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.36); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}

View File

@ -2,8 +2,9 @@ import { Component, Inject, PLATFORM_ID, OnInit, OnDestroy } from '@angular/core
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir } from '../../services/chips-controle.service'; import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir, UpdateChipVirgemRequest, UpdateControleRecebidoRequest, CreateChipVirgemRequest, CreateControleRecebidoRequest } from '../../services/chips-controle.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
// Interface para o Agrupamento // Interface para o Agrupamento
interface ChipGroup { interface ChipGroup {
@ -18,6 +19,13 @@ interface ControleGroup {
items: ControleRecebidoListDto[]; items: ControleRecebidoListDto[];
} }
interface ChipVirgemCreateModel {
id: string;
item: number | null;
numeroDoChip: string | null;
observacoes: string | null;
}
type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes'; type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes';
type ControleSortKey = type ControleSortKey =
| 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha' | 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha'
@ -82,19 +90,45 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
chipDetailOpen = false; chipDetailOpen = false;
chipDetailLoading = false; chipDetailLoading = false;
chipDetailData: ChipVirgemListDto | null = null; chipDetailData: ChipVirgemListDto | null = null;
chipCreateOpen = false;
chipCreateSaving = false;
chipCreateModel: ChipVirgemCreateModel | null = null;
chipEditOpen = false;
chipEditSaving = false;
chipEditModel: ChipVirgemListDto | null = null;
chipEditingId: string | null = null;
chipDeleteOpen = false;
chipDeleteTarget: ChipVirgemListDto | null = null;
controleDetailOpen = false; controleDetailOpen = false;
controleDetailLoading = false; controleDetailLoading = false;
controleDetailData: ControleRecebidoListDto | null = null; controleDetailData: ControleRecebidoListDto | null = null;
controleCreateOpen = false;
controleCreateSaving = false;
controleCreateModel: ControleRecebidoListDto | null = null;
controleCreateDataNf = '';
controleCreateRecebimento = '';
controleEditOpen = false;
controleEditSaving = false;
controleEditModel: ControleRecebidoListDto | null = null;
controleEditDataNf = '';
controleEditRecebimento = '';
controleEditingId: string | null = null;
controleDeleteOpen = false;
controleDeleteTarget: ControleRecebidoListDto | null = null;
isAdmin = false;
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private service: ChipsControleService, private service: ChipsControleService,
private http: HttpClient private http: HttpClient,
private authService: AuthService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('admin');
this.fetchChips(); this.fetchChips();
this.fetchControle(); this.fetchControle();
} }
@ -200,6 +234,118 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
}); });
} }
openChipCreate() {
if (!this.isAdmin) return;
this.chipCreateModel = {
id: '',
item: null,
numeroDoChip: '',
observacoes: ''
};
this.chipCreateOpen = true;
this.chipCreateSaving = false;
}
closeChipCreate() {
this.chipCreateOpen = false;
this.chipCreateSaving = false;
this.chipCreateModel = null;
}
saveChipCreate() {
if (!this.chipCreateModel) return;
this.chipCreateSaving = true;
const payload: CreateChipVirgemRequest = {
item: this.toNullableNumber(this.chipCreateModel.item),
numeroDoChip: this.chipCreateModel.numeroDoChip,
observacoes: this.chipCreateModel.observacoes
};
this.service.createChipVirgem(payload).subscribe({
next: () => {
this.chipCreateSaving = false;
this.closeChipCreate();
this.fetchChips();
this.showToast('Chip criado com sucesso!', 'success');
},
error: () => {
this.chipCreateSaving = false;
this.showToast('Erro ao criar chip.', 'danger');
}
});
}
openChipEdit(row: ChipVirgemListDto) {
if (!this.isAdmin) return;
this.service.getChipVirgemById(row.id).subscribe({
next: (data) => {
this.chipEditingId = data.id;
this.chipEditModel = { ...data };
this.chipEditOpen = true;
},
error: () => this.showToast('Erro ao abrir edição.', 'danger')
});
}
closeChipEdit() {
this.chipEditOpen = false;
this.chipEditSaving = false;
this.chipEditModel = null;
this.chipEditingId = null;
}
saveChipEdit() {
if (!this.chipEditModel || !this.chipEditingId) return;
this.chipEditSaving = true;
const payload: UpdateChipVirgemRequest = {
item: this.toNullableNumber(this.chipEditModel.item),
numeroDoChip: this.chipEditModel.numeroDoChip,
observacoes: this.chipEditModel.observacoes
};
this.service.updateChipVirgem(this.chipEditingId, payload).subscribe({
next: () => {
this.chipEditSaving = false;
this.closeChipEdit();
this.fetchChips();
this.showToast('Chip atualizado!', 'success');
},
error: () => {
this.chipEditSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
openChipDelete(row: ChipVirgemListDto) {
if (!this.isAdmin) return;
this.chipDeleteTarget = row;
this.chipDeleteOpen = true;
}
cancelChipDelete() {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
}
confirmChipDelete() {
if (!this.chipDeleteTarget) return;
const id = this.chipDeleteTarget.id;
this.service.removeChipVirgem(id).subscribe({
next: () => {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
this.fetchChips();
this.showToast('Chip removido.', 'success');
},
error: () => {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
closeChipDetail() { closeChipDetail() {
this.chipDetailOpen = false; this.chipDetailOpen = false;
this.chipDetailLoading = false; this.chipDetailLoading = false;
@ -349,6 +495,216 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
}); });
} }
openControleCreate() {
if (!this.isAdmin) return;
this.controleCreateModel = {
id: '',
ano: new Date().getFullYear(),
item: null,
notaFiscal: '',
chip: '',
serial: '',
conteudoDaNf: '',
numeroDaLinha: '',
valorUnit: null,
valorDaNf: null,
dataDaNf: null,
dataDoRecebimento: null,
quantidade: null,
isResumo: false
} as ControleRecebidoListDto;
this.controleCreateDataNf = '';
this.controleCreateRecebimento = '';
this.controleCreateOpen = true;
this.controleCreateSaving = false;
}
closeControleCreate() {
this.controleCreateOpen = false;
this.controleCreateSaving = false;
this.controleCreateModel = null;
this.controleCreateDataNf = '';
this.controleCreateRecebimento = '';
}
onControleCreateValueChange() {
if (!this.controleCreateModel) return;
this.recalculateControleTotals(this.controleCreateModel);
}
onControleEditValueChange() {
if (!this.controleEditModel) return;
this.recalculateControleTotals(this.controleEditModel);
}
onControleCreateDateChange() {
if (!this.controleCreateModel) return;
if (!this.controleCreateModel.ano && this.controleCreateDataNf) {
const year = new Date(this.controleCreateDataNf).getFullYear();
if (Number.isFinite(year)) this.controleCreateModel.ano = year;
}
}
onControleEditDateChange() {
if (!this.controleEditModel) return;
if (!this.controleEditModel.ano && this.controleEditDataNf) {
const year = new Date(this.controleEditDataNf).getFullYear();
if (Number.isFinite(year)) this.controleEditModel.ano = year;
}
}
private recalculateControleTotals(model: ControleRecebidoListDto) {
const quantidade = this.toNullableNumber(model.quantidade);
const valorUnit = this.toNullableNumber(model.valorUnit);
const valorDaNf = this.toNullableNumber(model.valorDaNf);
if (quantidade != null && valorUnit != null && (valorDaNf == null || valorDaNf === 0)) {
model.valorDaNf = Number((valorUnit * quantidade).toFixed(2));
} else if (quantidade != null && valorDaNf != null && (valorUnit == null || valorUnit === 0)) {
model.valorUnit = Number((valorDaNf / quantidade).toFixed(2));
}
}
saveControleCreate() {
if (!this.controleCreateModel) return;
this.recalculateControleTotals(this.controleCreateModel);
this.controleCreateSaving = true;
const payload: CreateControleRecebidoRequest = {
ano: this.toNullableNumber(this.controleCreateModel.ano),
item: this.toNullableNumber(this.controleCreateModel.item),
notaFiscal: this.controleCreateModel.notaFiscal,
chip: this.controleCreateModel.chip,
serial: this.controleCreateModel.serial,
conteudoDaNf: this.controleCreateModel.conteudoDaNf,
numeroDaLinha: this.controleCreateModel.numeroDaLinha,
valorUnit: this.toNullableNumber(this.controleCreateModel.valorUnit),
valorDaNf: this.toNullableNumber(this.controleCreateModel.valorDaNf),
dataDaNf: this.dateInputToIso(this.controleCreateDataNf),
dataDoRecebimento: this.dateInputToIso(this.controleCreateRecebimento),
quantidade: this.toNullableNumber(this.controleCreateModel.quantidade),
isResumo: this.controleCreateModel.isResumo ?? false
};
this.service.createControleRecebido(payload).subscribe({
next: () => {
this.controleCreateSaving = false;
this.closeControleCreate();
this.fetchControle();
this.showToast('Recebimento criado com sucesso!', 'success');
},
error: () => {
this.controleCreateSaving = false;
this.showToast('Erro ao criar recebimento.', 'danger');
}
});
}
openControleEdit(row: ControleRecebidoListDto) {
if (!this.isAdmin) return;
this.service.getControleRecebidoById(row.id).subscribe({
next: (data) => {
this.controleEditingId = data.id;
this.controleEditModel = { ...data };
this.controleEditDataNf = this.toDateInput(data.dataDaNf);
this.controleEditRecebimento = this.toDateInput(data.dataDoRecebimento);
this.controleEditOpen = true;
},
error: () => this.showToast('Erro ao abrir edição.', 'danger')
});
}
closeControleEdit() {
this.controleEditOpen = false;
this.controleEditSaving = false;
this.controleEditModel = null;
this.controleEditDataNf = '';
this.controleEditRecebimento = '';
this.controleEditingId = null;
}
saveControleEdit() {
if (!this.controleEditModel || !this.controleEditingId) return;
this.recalculateControleTotals(this.controleEditModel);
this.controleEditSaving = true;
const payload: UpdateControleRecebidoRequest = {
ano: this.toNullableNumber(this.controleEditModel.ano),
item: this.toNullableNumber(this.controleEditModel.item),
notaFiscal: this.controleEditModel.notaFiscal,
chip: this.controleEditModel.chip,
serial: this.controleEditModel.serial,
conteudoDaNf: this.controleEditModel.conteudoDaNf,
numeroDaLinha: this.controleEditModel.numeroDaLinha,
valorUnit: this.toNullableNumber(this.controleEditModel.valorUnit),
valorDaNf: this.toNullableNumber(this.controleEditModel.valorDaNf),
dataDaNf: this.dateInputToIso(this.controleEditDataNf),
dataDoRecebimento: this.dateInputToIso(this.controleEditRecebimento),
quantidade: this.toNullableNumber(this.controleEditModel.quantidade),
isResumo: this.controleEditModel.isResumo ?? false
};
this.service.updateControleRecebido(this.controleEditingId, payload).subscribe({
next: () => {
this.controleEditSaving = false;
this.closeControleEdit();
this.fetchControle();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.controleEditSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
openControleDelete(row: ControleRecebidoListDto) {
if (!this.isAdmin) return;
this.controleDeleteTarget = row;
this.controleDeleteOpen = true;
}
cancelControleDelete() {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
}
confirmControleDelete() {
if (!this.controleDeleteTarget) return;
const id = this.controleDeleteTarget.id;
this.service.removeControleRecebido(id).subscribe({
next: () => {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
this.fetchControle();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
closeControleDetail() { closeControleDetail() {
this.controleDetailOpen = false; this.controleDetailOpen = false;
this.controleDetailLoading = false; this.controleDetailLoading = false;

View File

@ -22,16 +22,19 @@
<div class="geral-header"> <div class="geral-header">
<div class="header-row-top"> <div class="header-row-top">
<div class="title-badge" data-animate> <div class="title-badge" data-animate>
<i class="bi bi-people-fill"></i> DADOS USUÁRIOS <i class="bi bi-people-fill"></i> DADOS PF/PJ
</div> </div>
<div class="header-title" data-animate> <div class="header-title" data-animate>
<h5 class="title mb-0">GESTÃO DE USUÁRIOS</h5> <h5 class="title mb-0">GESTÃO DE USUÁRIOS PF/PJ</h5>
<small class="subtitle">Base de dados agrupada por cliente</small> <small class="subtitle">Base de dados separada por pessoa física e jurídica</small>
</div> </div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate> <div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading"> <button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar <i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button> </button>
<button *ngIf="isAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button>
</div> </div>
</div> </div>
@ -51,10 +54,10 @@
</span> </span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl text-success">Com CPF</span> <span class="lbl text-success">{{ tipoFilter === 'PJ' ? 'Com CNPJ' : 'Com CPF' }}</span>
<span class="val text-success"> <span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span> <span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiComCpf || 0 }}</span> <span *ngIf="!loading">{{ tipoFilter === 'PJ' ? (kpiComCnpj || 0) : (kpiComCpf || 0) }}</span>
</span> </span>
</div> </div>
<div class="kpi"> <div class="kpi">
@ -67,9 +70,17 @@
</div> </div>
<div class="controls mt-3 mb-2" data-animate> <div class="controls mt-3 mb-2" data-animate>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm" [class.btn-brand]="tipoFilter === 'PF'" [class.btn-outline-secondary]="tipoFilter !== 'PF'" (click)="setTipoFilter('PF')">
Pessoa Física
</button>
<button type="button" class="btn btn-sm" [class.btn-brand]="tipoFilter === 'PJ'" [class.btn-outline-secondary]="tipoFilter !== 'PJ'" (click)="setTipoFilter('PJ')">
Pessoa Jurídica
</button>
</div>
<div class="input-group input-group-sm search-group"> <div class="input-group input-group-sm search-group">
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span> <span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span>
<input class="form-control" placeholder="Pesquisar cliente, linha, cpf..." [(ngModel)]="search" (ngModelChange)="onSearch()" /> <input class="form-control" placeholder="Pesquisar..." [(ngModel)]="search" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearFilters()" *ngIf="search"><i class="bi bi-x-lg"></i></button> <button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearFilters()" *ngIf="search"><i class="bi bi-x-lg"></i></button>
</div> </div>
@ -99,7 +110,8 @@
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6> <h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges"> <div class="group-badges">
<span class="badge-pill total">{{ g.totalRegistros }} Registros</span> <span class="badge-pill total">{{ g.totalRegistros }} Registros</span>
<span class="badge-pill ok" *ngIf="g.comCpf > 0">{{ g.comCpf }} CPF</span> <span class="badge-pill ok" *ngIf="tipoFilter === 'PF' && g.comCpf > 0">{{ g.comCpf }} CPF</span>
<span class="badge-pill ok" *ngIf="tipoFilter === 'PJ' && g.comCnpj > 0">{{ g.comCnpj }} CNPJ</span>
<span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span> <span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span>
</div> </div>
</div> </div>
@ -122,10 +134,10 @@
<tr> <tr>
<th>ITEM</th> <th>ITEM</th>
<th>LINHA</th> <th>LINHA</th>
<th>CPF</th> <th>{{ tipoFilter === 'PJ' ? 'CNPJ' : 'CPF' }}</th>
<th>E-MAIL</th> <th>E-MAIL</th>
<th>CELULAR</th> <th>CELULAR</th>
<th style="min-width: 80px;">AÇÕES</th> <th class="actions-col">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -135,12 +147,14 @@
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item"> <tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item }}</td> <td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha }}</td> <td class="fw-black text-blue">{{ r.linha }}</td>
<td class="small font-monospace">{{ r.cpf || '-' }}</td> <td class="small font-monospace">{{ tipoFilter === 'PJ' ? (r.cnpj || '-') : (r.cpf || '-') }}</td>
<td class="text-muted small td-clip" [title]="r.email">{{ r.email || '-' }}</td> <td class="text-muted small td-clip" [title]="r.email">{{ r.email || '-' }}</td>
<td class="text-muted small">{{ r.celular || '-' }}</td> <td class="text-muted small">{{ r.celular || '-' }}</td>
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -167,9 +181,9 @@
</div> </div>
</section> </section>
<div class="modal-backdrop-custom" *ngIf="detailsOpen" (click)="closeDetails()"></div> <div class="modal-backdrop-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="modal-custom" *ngIf="detailsOpen"> <div class="modal-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()"> <div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span> <span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
@ -184,10 +198,11 @@
<div class="box-body"> <div class="box-body">
<div class="form-grid"> <div class="form-grid">
<div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div> <div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div>
<div class="form-field"><label>TIPO</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'PESSOA JURÍDICA' : 'PESSOA FÍSICA' }}</div></div>
<div class="form-field span-2"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'RAZÃO SOCIAL' : 'NOME' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.razaoSocial || selectedRow?.cliente || '-') : (selectedRow?.nome || selectedRow?.cliente || '-') }}</div></div>
<div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div> <div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div>
<div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div> <div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div>
<div class="form-field"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'CNPJ' : 'CPF' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.cnpj || '-') : (selectedRow?.cpf || '-') }}</div></div>
<div class="form-field"><label>CPF</label><div>{{ selectedRow?.cpf || '-' }}</div></div>
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div> <div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div>
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div> <div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
@ -202,5 +217,205 @@
</div> </div>
</div> </div>
</div> </div>
<!-- CREATE MODAL -->
<div *ngIf="createOpen" class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Usuário
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="createModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com GERAL</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="clientsFromGeral"
[(ngModel)]="createModel.selectedClient"
(ngModelChange)="onCreateClientChange()"
[disabled]="createClientsLoading"
></app-select>
</div>
<div class="form-field span-2">
<label>Linha (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-vcard me-2"></i> Dados do Usuário</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="createModel.tipoPessoa" (ngModelChange)="onCreateTipoChange()">
<option value="PF">Pessoa Física</option>
<option value="PJ">Pessoa Jurídica</option>
</select>
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.razaoSocial" />
</div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="createModel.linha" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.item" /></div>
<div class="form-field" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>CPF</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cpf" /></div>
<div class="form-field" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'"><label>CNPJ</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cnpj" /></div>
<div class="form-field"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="createModel.rg" /></div>
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" [(ngModel)]="createModel.email" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="createModel.endereco" /></div>
<div class="form-field"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="createModel.celular" /></div>
<div class="form-field"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="createModel.telefoneFixo" /></div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>Data de Nascimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createDateNascimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (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 Usuário
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipoPessoa" (ngModelChange)="onEditTipoChange()">
<option value="PF">Pessoa Física</option>
<option value="PJ">Pessoa Jurídica</option>
</select>
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.razaoSocial" />
</div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" /></div>
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>CPF</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cpf" />
</div>
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>CNPJ</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cnpj" />
</div>
<div class="form-field"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="editModel.rg" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-envelope-paper me-2"></i> Contato</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" type="email" [(ngModel)]="editModel.email" /></div>
<div class="form-field"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="editModel.celular" /></div>
<div class="form-field"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="editModel.telefoneFixo" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="editModel.endereco" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Complemento</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Data Nascimento</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editDateNascimento" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Usuário
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div> </div>

View File

@ -112,7 +112,22 @@
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; } .header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; } .title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; } .subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions { justify-self: end; } .header-actions {
justify-self: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
white-space: nowrap;
}
}
/* Buttons */ /* Buttons */
.btn-brand { .btn-brand {
@ -241,12 +256,24 @@
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; } .td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
.empty-state { background: rgba(255,255,255,0.4); } .empty-state { background: rgba(255,255,255,0.4); }
.actions-col { min-width: 152px; }
.action-group {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.btn-icon { .btn-icon {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer; color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer;
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); } &.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
} }
/* FOOTER */ /* FOOTER */
@ -256,17 +283,167 @@
/* MODALS */ /* MODALS */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: clamp(12px, 2.2vw, 20px); }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-xl-custom { width: min(1050px, 95vw); max-height: 86vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } } .modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } }
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
.modal-card.create-modal .edit-sections { gap: 14px; }
.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
.modal-card.create-modal .form-control,
.modal-card.create-modal .form-select { min-height: 40px; }
.modal-card.create-modal .modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 14px 20px !important;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
}
.modal-card.create-modal .modal-footer .btn {
border-radius: 12px;
font-weight: 900;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
.modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
.modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
}
/* FORM & DETAILS */ /* FORM & DETAILS */
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } .details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; } div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
div.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; } div.box-header {
padding: 10px 16px;
font-size: 0.8rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--brand);
border-bottom: 1px solid rgba(0,0,0,0.04);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
display: flex;
align-items: center;
}
div.box-body { padding: 16px; } div.box-body { padding: 16px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } }
.form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } } .edit-sections { display: grid; gap: 12px; }
.form-control { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); &:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; } } .edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
summary.box-header {
cursor: pointer;
user-select: none;
list-style: none;
i:not(.transition-icon) {
color: var(--brand);
margin-right: 6px;
}
&::-webkit-details-marker { display: none; }
}
.transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17,18,20,0.64);
}
&.span-2 { grid-column: span 2; }
}
.details-dashboard .form-field > div {
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
background: rgba(245, 245, 247, 0.65);
min-height: 42px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 700;
color: var(--text);
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.15);
background: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.38); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}

View File

@ -9,11 +9,23 @@ import {
UserDataClientGroup, UserDataClientGroup,
UserDataRow, UserDataRow,
UserDataGroupResponse, UserDataGroupResponse,
PagedResult PagedResult,
UpdateUserDataRequest,
CreateUserDataRequest
} from '../../services/dados-usuarios.service'; } from '../../services/dados-usuarios.service';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
type ViewMode = 'lines' | 'groups'; type ViewMode = 'lines' | 'groups';
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
usuario: string | null;
label?: string;
}
@Component({ @Component({
selector: 'app-dados-usuarios', selector: 'app-dados-usuarios',
standalone: true, standalone: true,
@ -30,6 +42,7 @@ export class DadosUsuarios implements OnInit {
// Filtros // Filtros
search = ''; search = '';
tipoFilter: 'PF' | 'PJ' = 'PF';
// Paginação // Paginação
page = 1; page = 1;
@ -52,6 +65,7 @@ export class DadosUsuarios implements OnInit {
kpiTotalRegistros = 0; kpiTotalRegistros = 0;
kpiClientesUnicos = 0; kpiClientesUnicos = 0;
kpiComCpf = 0; kpiComCpf = 0;
kpiComCnpj = 0;
kpiComEmail = 0; kpiComEmail = 0;
// ACORDEÃO // ACORDEÃO
@ -62,15 +76,38 @@ export class DadosUsuarios implements OnInit {
// Modal / Toast // Modal / Toast
detailsOpen = false; detailsOpen = false;
selectedRow: UserDataRow | null = null; selectedRow: UserDataRow | null = null;
editOpen = false;
editSaving = false;
editModel: UserDataRow | null = null;
editDateNascimento = '';
editingId: string | null = null;
deleteOpen = false;
deleteTarget: UserDataRow | null = null;
createOpen = false;
createSaving = false;
createModel: any = null;
createDateNascimento = '';
clientsFromGeral: string[] = [];
lineOptionsCreate: LineOptionDto[] = [];
createClientsLoading = false;
createLinesLoading = false;
isAdmin = false;
toastOpen = false; toastOpen = false;
toastMessage = ''; toastMessage = '';
toastType: 'success' | 'danger' = 'success'; toastType: 'success' | 'danger' = 'success';
private toastTimer: any = null; private toastTimer: any = null;
private searchTimer: any = null; private searchTimer: any = null;
constructor(private service: DadosUsuariosService) {} constructor(
private service: DadosUsuariosService,
private authService: AuthService,
private linesService: LinesService
) {}
ngOnInit(): void { ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.fetch(1); this.fetch(1);
} }
@ -128,6 +165,7 @@ export class DadosUsuarios implements OnInit {
private fetchGroups() { private fetchGroups() {
this.service.getGroups({ this.service.getGroups({
search: this.search?.trim(), search: this.search?.trim(),
tipo: this.tipoFilter,
page: this.page, page: this.page,
pageSize: this.pageSize, pageSize: this.pageSize,
sortBy: this.sortBy, sortBy: this.sortBy,
@ -140,6 +178,7 @@ export class DadosUsuarios implements OnInit {
this.kpiTotalRegistros = res.kpis.totalRegistros; this.kpiTotalRegistros = res.kpis.totalRegistros;
this.kpiClientesUnicos = res.kpis.clientesUnicos; this.kpiClientesUnicos = res.kpis.clientesUnicos;
this.kpiComCpf = res.kpis.comCpf; this.kpiComCpf = res.kpis.comCpf;
this.kpiComCnpj = res.kpis.comCnpj;
this.kpiComEmail = res.kpis.comEmail; this.kpiComEmail = res.kpis.comEmail;
this.loading = false; this.loading = false;
@ -168,6 +207,7 @@ export class DadosUsuarios implements OnInit {
this.service.getRows({ this.service.getRows({
client: g.cliente, client: g.cliente,
tipo: this.tipoFilter,
page: 1, page: 1,
pageSize: 200, pageSize: 200,
sortBy: 'item', sortBy: 'item',
@ -193,6 +233,15 @@ export class DadosUsuarios implements OnInit {
}, 400); }, 400);
} }
setTipoFilter(tipo: 'PF' | 'PJ') {
if (this.tipoFilter === tipo) return;
this.tipoFilter = tipo;
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.fetch();
}
clearFilters() { this.search = ''; this.fetch(1); } clearFilters() { this.search = ''; this.fetch(1); }
onPageSizeChange() { onPageSizeChange() {
@ -208,7 +257,13 @@ export class DadosUsuarios implements OnInit {
openDetails(row: UserDataRow) { openDetails(row: UserDataRow) {
this.service.getById(row.id).subscribe({ this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => { next: (fullData: UserDataRow) => {
this.selectedRow = fullData; const tipo = this.normalizeTipo(fullData);
this.selectedRow = {
...fullData,
tipoPessoa: tipo,
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
};
this.detailsOpen = true; this.detailsOpen = true;
}, },
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger') error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
@ -217,9 +272,315 @@ export class DadosUsuarios implements OnInit {
closeDetails() { this.detailsOpen = false; } closeDetails() { this.detailsOpen = false; }
openEdit(row: UserDataRow) {
if (!this.isAdmin) return;
this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => {
this.editingId = fullData.id;
const tipo = this.normalizeTipo(fullData);
this.editModel = {
...fullData,
tipoPessoa: tipo,
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
};
this.editDateNascimento = this.toDateInput(fullData.dataNascimento);
this.editOpen = true;
},
error: () => this.showToast('Erro ao abrir edição', 'danger')
});
}
closeEdit() {
this.editOpen = false;
this.editSaving = false;
this.editModel = null;
this.editDateNascimento = '';
this.editingId = null;
}
onEditTipoChange() {
if (!this.editModel) return;
const tipo = (this.editModel.tipoPessoa ?? 'PF').toString().toUpperCase();
this.editModel.tipoPessoa = tipo;
if (tipo === 'PJ') {
this.editModel.cpf = '';
if (!this.editModel.razaoSocial) this.editModel.razaoSocial = this.editModel.cliente;
} else {
this.editModel.cnpj = '';
if (!this.editModel.nome) this.editModel.nome = this.editModel.cliente;
}
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const tipo = (this.editModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
const cliente = tipo === 'PJ'
? (this.editModel.razaoSocial || this.editModel.cliente)
: (this.editModel.nome || this.editModel.cliente);
const payload: UpdateUserDataRequest = {
item: this.toNullableNumber(this.editModel.item),
linha: this.editModel.linha,
cliente,
tipoPessoa: tipo,
nome: this.editModel.nome,
razaoSocial: this.editModel.razaoSocial,
cnpj: this.editModel.cnpj,
cpf: this.editModel.cpf,
rg: this.editModel.rg,
email: this.editModel.email,
endereco: this.editModel.endereco,
celular: this.editModel.celular,
telefoneFixo: this.editModel.telefoneFixo,
dataNascimento: this.dateInputToIso(this.editDateNascimento)
};
this.service.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.fetch();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.editSaving = false;
this.showToast('Erro ao salvar alterações.', 'danger');
}
});
}
// ==========================
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
}
closeCreate() {
this.createOpen = false;
this.createSaving = false;
this.createModel = null;
}
private resetCreateModel() {
this.createModel = {
selectedClient: '',
mobileLineId: '',
item: '',
linha: '',
cliente: '',
tipoPessoa: this.tipoFilter,
nome: '',
razaoSocial: '',
cnpj: '',
cpf: '',
rg: '',
email: '',
endereco: '',
celular: '',
telefoneFixo: ''
};
this.createDateNascimento = '';
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.createClientsLoading = false;
}
private preloadGeralClients() {
this.createClientsLoading = true;
this.linesService.getClients().subscribe({
next: (list) => {
this.clientsFromGeral = list ?? [];
this.createClientsLoading = false;
},
error: () => {
this.clientsFromGeral = [];
this.createClientsLoading = false;
}
});
}
onCreateClientChange() {
const c = (this.createModel?.selectedClient ?? '').trim();
this.createModel.mobileLineId = '';
this.createModel.linha = '';
this.createModel.cliente = c;
this.lineOptionsCreate = [];
if (c) this.loadLinesForClient(c);
}
onCreateTipoChange() {
const tipo = (this.createModel?.tipoPessoa ?? 'PF').toString().toUpperCase();
this.createModel.tipoPessoa = tipo;
if (tipo === 'PJ') {
this.createModel.cpf = '';
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
} else {
this.createModel.cnpj = '';
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
}
}
private loadLinesForClient(cliente: string) {
const c = (cliente ?? '').trim();
if (!c) return;
this.createLinesLoading = true;
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,
usuario: x.usuario ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());
this.lineOptionsCreate = mapped;
this.createLinesLoading = false;
},
error: () => {
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
}
});
}
onCreateLineChange() {
const id = String(this.createModel?.mobileLineId ?? '').trim();
if (!id) return;
this.linesService.getById(id).subscribe({
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
});
}
private applyLineDetailToCreate(d: MobileLineDetail) {
this.createModel.linha = d.linha ?? '';
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
if (!String(this.createModel.item ?? '').trim() && d.item) {
this.createModel.item = String(d.item);
}
if ((this.createModel.tipoPessoa ?? '').toUpperCase() === 'PJ') {
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
} else {
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
}
}
saveCreate() {
if (!this.createModel) return;
this.createSaving = true;
const tipo = (this.createModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
const cliente = tipo === 'PJ'
? (this.createModel.razaoSocial || this.createModel.cliente)
: (this.createModel.nome || this.createModel.cliente);
const payload: CreateUserDataRequest = {
item: this.toNullableNumber(this.createModel.item),
linha: this.createModel.linha,
cliente,
tipoPessoa: tipo,
nome: this.createModel.nome,
razaoSocial: this.createModel.razaoSocial,
cnpj: this.createModel.cnpj,
cpf: this.createModel.cpf,
rg: this.createModel.rg,
email: this.createModel.email,
endereco: this.createModel.endereco,
celular: this.createModel.celular,
telefoneFixo: this.createModel.telefoneFixo,
dataNascimento: this.dateInputToIso(this.createDateNascimento)
};
this.service.create(payload).subscribe({
next: () => {
this.createSaving = false;
this.closeCreate();
this.fetch();
this.showToast('Usuário criado com sucesso!', 'success');
},
error: () => {
this.createSaving = false;
this.showToast('Erro ao criar usuário.', 'danger');
}
});
}
openDelete(row: UserDataRow) {
if (!this.isAdmin) return;
this.deleteTarget = row;
this.deleteOpen = true;
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
}
confirmDelete() {
if (!this.deleteTarget) return;
const id = this.deleteTarget.id;
this.service.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.fetch();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
trackById(_: number, row: UserDataRow) { return row.id; } trackById(_: number, row: UserDataRow) { return row.id; }
trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; } trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; }
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
private normalizeTipo(row: UserDataRow | null | undefined): 'PF' | 'PJ' {
const t = (row?.tipoPessoa ?? '').toString().trim().toUpperCase();
if (t === 'PJ') return 'PJ';
if (t === 'PF') return 'PF';
if (row?.cnpj) return 'PJ';
return 'PF';
}
showToast(msg: string, type: 'success' | 'danger') { showToast(msg: string, type: 'success' | 'danger') {
this.toastMessage = msg; this.toastType = type; this.toastOpen = true; this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
if(this.toastTimer) clearTimeout(this.toastTimer); if(this.toastTimer) clearTimeout(this.toastTimer);

View File

@ -1,275 +1,327 @@
<section class="dashboard-page"> <section class="dashboard-page">
<div class="wrap"> <span class="page-blob blob-1" aria-hidden="true"></span>
<div class="container"> <span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<div class="container-dashboard">
<div class="page-head fade-in-up"> <div class="page-head fade-in-up">
<div class="title"> <div class="head-content">
<span class="badge"> <div class="badge-pill">
<i class="bi bi-bar-chart-fill"></i> Dashboard <i class="bi bi-grid-1x2-fill"></i> Visão Geral
</span> </div>
<p class="subtitle">Resumo e indicadores do ambiente.</p> <h1 class="page-title">Dashboard de Gestão de Linhas</h1>
<p class="page-subtitle">Painel operacional com foco em status, cobertura e histórico da base.</p>
</div> </div>
<div class="status" *ngIf="loading"> <div class="head-actions">
<i class="bi bi-arrow-repeat spin"></i> <div class="status-indicator" *ngIf="loading">
<span>Carregando...</span> <span class="spinner-border spinner-border-sm text-brand"></span>
<span>Atualizando dados...</span>
</div>
<div class="status-indicator error" *ngIf="!loading && errorMsg">
<i class="bi bi-exclamation-triangle-fill"></i> {{ errorMsg }}
</div>
<div class="last-update" *ngIf="!loading && !errorMsg">
<i class="bi bi-check2-circle"></i> Dados atualizados
</div> </div>
<div class="status warn" *ngIf="!loading && errorMsg">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ errorMsg }}</span>
</div> </div>
</div> </div>
<!-- KPIs --> <div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'">
<div class="kpi-grid"> <div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey">
<div class="kpi-card lift" *ngFor="let k of kpis; let i = index" [style.animationDelay.ms]="i * 40"> <div class="hero-icon">
<div class="kpi-icon">
<i [class]="k.icon"></i> <i [class]="k.icon"></i>
</div> </div>
<div class="kpi-content"> <div class="hero-data">
<div class="kpi-title">{{ k.title }}</div> <span class="hero-label">{{ k.title }}</span>
<div class="kpi-value">{{ k.value }}</div> <span class="hero-value">{{ k.value }}</span>
<div class="kpi-hint" *ngIf="k.hint">{{ k.hint }}</div> <span class="hero-hint" *ngIf="k.hint">{{ k.hint }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Status das linhas --> <div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
<div class="cardx fade-in-up"> <h2>Página Geral</h2>
<div class="cardx-head"> <p>Distribuição e saúde atual da base de linhas.</p>
<div class="cardx-title">
<i class="bi bi-pie-chart-fill"></i>
Status das linhas
</div>
</div> </div>
<div class="status-pie-grid"> <div class="dashboard-section fade-in-up" [style.animation-delay]="'220ms'">
<div class="pie-wrap"> <div class="section-top-row">
<div class="card-modern card-status">
<div class="card-header-clean">
<div class="header-icon brand"><i class="bi bi-pie-chart-fill"></i></div>
<div class="header-text">
<h3>Status da Base</h3>
<p>Distribuição atual das linhas</p>
</div>
</div>
<div class="card-body-split">
<div class="chart-wrapper-pie">
<canvas #chartStatusPie></canvas> <canvas #chartStatusPie></canvas>
</div> </div>
<div class="status-list">
<div class="status-metrics"> <div class="status-item">
<div class="metric total"> <span class="dot d-active"></span>
<span class="dot d1"></span> <span class="lbl">Ativas</span>
<div class="meta"> <span class="val">{{ statusResumo.ativos | number:'1.0-0' }}</span>
<div class="k">Total linhas</div> </div>
<div class="v">{{ statusResumo.total | number:'1.0-0' }}</div> <div class="status-item">
<span class="dot d-reserve"></span>
<span class="lbl">Reservas</span>
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
</div>
<div class="status-item">
<span class="dot d-blocked"></span>
<span class="lbl">Bloq. (Perda/Roubo)</span>
<span class="val">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</span>
</div>
<div class="status-item">
<span class="dot d-blocked-soft"></span>
<span class="lbl">Bloq. (120 dias)</span>
<span class="val">{{ statusResumo.bloq120 | number:'1.0-0' }}</span>
</div>
<div class="status-item total-row">
<span class="lbl">Total Geral</span>
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
</div>
</div>
</div> </div>
</div> </div>
<div class="metric"> <div class="card-modern card-adicionais">
<span class="dot d2"></span> <div class="card-header-clean">
<div class="meta"> <div class="header-icon blue"><i class="bi bi-diagram-3-fill"></i></div>
<div class="k">Ativas</div> <div class="header-text">
<div class="v">{{ statusResumo.ativos | number:'1.0-0' }}</div> <h3>Serviços Adicionais</h3>
<p>Comparativo de linhas com e sem adicionais (Geral)</p>
</div> </div>
</div> </div>
<div class="card-body-adicionais">
<div class="metric"> <div class="chart-wrapper-pie-sm">
<span class="dot d3"></span> <canvas #chartAdicionaisComparativo></canvas>
<div class="meta">
<div class="k">Bloqueadas (perda/roubo)</div>
<div class="v">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</div>
</div> </div>
<div class="compare-list">
<div class="compare-item">
<span class="dot d-com-add"></span>
<span class="lbl">Com adicionais</span>
<span class="val">{{ adicionaisComparativo.com | number:'1.0-0' }}</span>
<span class="pct">{{ adicionaisComparativo.pctCom }}</span>
</div> </div>
<div class="compare-item">
<div class="metric"> <span class="dot d-sem-add"></span>
<span class="dot d4"></span> <span class="lbl">Sem adicionais</span>
<div class="meta"> <span class="val">{{ adicionaisComparativo.sem | number:'1.0-0' }}</span>
<div class="k">Bloqueadas (120 dias)</div> <span class="pct">{{ adicionaisComparativo.pctSem }}</span>
<div class="v">{{ statusResumo.bloq120 | number:'1.0-0' }}</div>
</div> </div>
<div class="compare-item total-row">
<span class="lbl">Total analisado</span>
<span class="val">{{ adicionaisComparativo.total | number:'1.0-0' }}</span>
</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> </div>
</div> </div>
</div> </div>
<!-- ✅ NOVO POSICIONAMENTO: VIGÊNCIA abaixo de Status e acima dos gráficos 12 meses --> <div class="dashboard-section fade-in-up" [style.animation-delay]="'280ms'">
<div class="charts-grid charts-vigencia"> <div class="grid-halves">
<div class="cardx fade-in-up lift"> <div class="card-modern">
<div class="cardx-head"> <div class="card-header-clean">
<div class="cardx-title"> <div class="header-icon warning"><i class="bi bi-shield-exclamation"></i></div>
<i class="bi bi-calendar2-check"></i> <div class="header-text">
Contratos a encerrar (próximos 12 meses) <h3>Vigência (Buckets)</h3>
<p>Status de vencimento atual</p>
</div> </div>
</div> </div>
<div class="chart-wrapper-pie">
<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> <canvas #chartVigenciaSupervisao></canvas>
</div> </div>
</div> </div>
</div>
<!-- Charts (12 meses) --> <div class="card-modern">
<div class="charts-grid"> <div class="card-header-clean">
<div class="cardx fade-in-up lift"> <div class="header-icon blue"><i class="bi bi-globe2"></i></div>
<div class="cardx-head"> <div class="header-text">
<div class="cardx-title"> <h3>Vivo Travel</h3>
<i class="bi bi-arrow-repeat"></i> <p>Linhas com e sem serviço ativo</p>
MUREG (últimos 12 meses) </div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartTravelMundo></canvas>
</div>
</div> </div>
</div> </div>
<div class="chart-wrap"> <div class="grid-triples mt-3">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Linhas por Franquia</h3>
<p>Distribuição da base por faixa de franquia</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartLinhasPorFranquia></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Adicionais Pagos (Serviços)</h3>
<p>Quantidade de linhas por serviço adicional ativo</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartAdicionaisPagos></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Tipo de Chip</h3>
<p>Quantidade de linhas e-SIM e SIMCARD</p>
</div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartTipoChip></canvas>
</div>
</div>
</div>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
<h2>Página Resumo</h2>
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'360ms'">
<div class="card-modern full-width">
<div class="toolbar-header">
<div class="title-group">
<i class="bi bi-bar-chart-line text-brand"></i>
<div>
<h3>Resumo Operacional de Linhas</h3>
<p>Dados consolidados da página Resumo sem foco financeiro</p>
</div>
</div>
<div class="toolbar-controls">
<div class="control-group">
<label>Visualizar Top</label>
<select
[value]="resumoTopN"
(change)="resumoTopN = +($any($event.target).value); onResumoTopNChange()"
class="form-select-sm">
<option *ngFor="let size of resumoTopOptions" [value]="size">{{ size }}</option>
</select>
</div>
<div class="divider-v"></div>
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
</div>
</div>
<div class="card-body-grid">
<div class="loading-overlay" *ngIf="resumoLoading">
<div class="spinner-border text-brand" role="status"></div>
</div>
<div class="error-state" *ngIf="!resumoLoading && resumoError">
<i class="bi bi-exclamation-circle"></i>
<span>{{ resumoError }}</span>
</div>
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
<div class="mini-chart-card">
<h6>Top Clientes (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopClientes></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Top Planos (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
</div>
<div class="mini-chart-card">
<h6>PF vs PJ (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Reserva por DDD</h6>
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
</div>
<div class="mini-chart-card mini-metric-card">
<h6>DIFERENÇA PJ X PF</h6>
<div class="metric-stack">
<div class="metric-line">
<span>Valor Total Line</span>
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.valorTotalLine) }}</strong>
</div>
<div class="metric-line">
<span>Lucro Total Line</span>
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.lucroTotalLine) }}</strong>
</div>
<div class="metric-line">
<span>Qtd Linhas</span>
<strong>{{ formatInt(resumoDiferencaPjPf.qtdLinhas) }}</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'420ms'">
<h2>Histórico</h2>
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
<div class="history-grid">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>MUREG (12 Meses)</h3>
<p>Histórico mensal de mudanças de plano/aparelho</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartMureg12></canvas> <canvas #chartMureg12></canvas>
</div> </div>
</div> </div>
<div class="cardx fade-in-up lift"> <div class="card-modern">
<div class="cardx-head"> <div class="card-header-clean">
<div class="cardx-title"> <div class="header-text">
<i class="bi bi-shuffle"></i> <h3>Troca de Número (12 Meses)</h3>
Troca de número (últimos 12 meses) <p>Histórico mensal de trocas realizadas</p>
</div> </div>
</div> </div>
<div class="chart-wrapper-bar compact-half">
<div class="chart-wrap">
<canvas #chartTroca12></canvas> <canvas #chartTroca12></canvas>
</div> </div>
</div> </div>
</div>
<!-- Top Clientes --> <div class="card-modern">
<div class="cardx fade-in-up"> <div class="card-header-clean">
<div class="cardx-head"> <div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
<div class="cardx-title"> <div class="header-text">
<i class="bi bi-trophy-fill"></i> <h3>Vigência (Próx. 12 Meses)</h3>
Top clientes (por linhas) <p>Contratos a encerrar por mês</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartVigenciaMesAno></canvas>
</div>
</div>
</div> </div>
</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> </div>
</section> </section>

View File

@ -1,22 +1,42 @@
/* ========================================================== */
/* VARIÁVEIS & SETUP (Consistente com Geral/Resumo) */
/* ========================================================== */
:host { :host {
display: block; --brand: #E33DCF;
width: 100%; --brand-soft: rgba(227, 61, 207, 0.08);
overflow-x: hidden; --brand-hover: #c92bb6;
--brand-primary: #E33DCF;
--brand-blue: #030FAA; --blue: #030FAA;
--brand-deep: #B832A8; --blue-soft: rgba(3, 15, 170, 0.08);
--brand-violet: #6A55FF;
--brand-soft: rgba(227, 61, 207, 0.2); --text-main: #111827;
--brand-blue-soft: rgba(3, 15, 170, 0.2); --text-muted: rgba(17, 18, 20, 0.65);
--chart-pink: var(--brand-primary);
--chart-pink-dark: var(--brand-deep); --bg-page: #f8fafc;
--card-bg: rgba(255, 255, 255, 0.9);
--glass-border: 1px solid rgba(255, 255, 255, 0.6);
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
--shadow-card: 0 10px 30px -5px rgba(0, 0, 0, 0.06);
--shadow-hover: 0 20px 40px -5px rgba(0, 0, 0, 0.1);
--radius-xl: 20px;
--radius-lg: 16px;
--radius-md: 12px;
/* Cores de Gráfico (Palette do Sistema) */
--chart-pink: #E33DCF;
--chart-blue: #030FAA;
--chart-purple: #6A55FF;
--chart-pink-soft: #F3B0E8; --chart-pink-soft: #F3B0E8;
--chart-blue: var(--brand-blue); --chart-dark: #B832A8;
--chart-blue-soft: var(--brand-blue-soft);
--chart-violet: var(--brand-violet); display: block;
font-family: 'Inter', sans-serif;
color: var(--text-main);
} }
/* ✅ remove footer nessa página */ /* Remove footer se necessário (herdado do antigo css) */
:host ::ng-deep footer, :host ::ng-deep footer,
:host ::ng-deep .footer, :host ::ng-deep .footer,
:host ::ng-deep .portal-footer, :host ::ng-deep .portal-footer,
@ -24,370 +44,579 @@
display: none !important; display: none !important;
} }
/* ========================================================== */
/* LAYOUT BASE */
/* ========================================================== */
.dashboard-page { .dashboard-page {
width: 100%; min-height: 100vh;
padding-bottom: 60px;
background:
radial-gradient(circle at 15% 10%, rgba(227, 61, 207, 0.08) 0%, transparent 40%),
radial-gradient(circle at 85% 30%, rgba(3, 15, 170, 0.06) 0%, transparent 40%),
var(--bg-page);
position: relative;
overflow-x: hidden; overflow-x: hidden;
} }
/* ✅ SUBIR MAIS A PÁGINA (antes 44px) */ .page-blob {
.wrap { position: fixed; pointer-events: none; border-radius: 999px;
padding-top: 15px; /* ✅ mais perto do header */ filter: blur(40px); opacity: 0.6; z-index: 0;
padding-bottom: 16px;
overflow-x: hidden; &.blob-1 { width: 300px; height: 300px; top: -100px; left: -50px; background: rgba(227,61,207,0.3); }
&.blob-2 { width: 400px; height: 400px; top: 20%; right: -100px; background: rgba(3,15,170,0.2); }
&.blob-3 { width: 350px; height: 350px; bottom: 0; left: 20%; background: rgba(106,85,255,0.2); }
} }
.container { .container-dashboard {
width: 100%; width: 100%;
max-width: 1200px; max-width: 1380px; /* Largura executiva */
margin: 0 auto; margin: 0 auto;
padding: 0 14px; padding: 24px 20px;
position: relative;
z-index: 1;
} }
.page-head { /* Animações */
display: flex; .fade-in-up {
align-items: flex-start; opacity: 0;
justify-content: space-between; animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
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 { @keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.lift { /* ========================================================== */
transition: transform 180ms ease, box-shadow 180ms ease; /* HEADER */
&:hover { /* ========================================================== */
transform: translateY(-2px); .page-head {
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; display: flex;
gap: 12px; justify-content: space-between;
align-items: center; align-items: flex-end;
padding: 14px; margin-bottom: 32px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82); @media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; }
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
min-width: 0;
} }
.kpi-icon { .badge-pill {
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; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
font-weight: 900; padding: 6px 12px;
font-family: 'Poppins', sans-serif; border-radius: 99px;
color: rgba(17, 18, 20, 0.86); background: #fff;
border: 1px solid rgba(0,0,0,0.08);
i { color: var(--brand-primary); } font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 12px;
box-shadow: var(--shadow-sm);
i { font-size: 14px; }
} }
/* Status grid */ .page-title {
.status-pie-grid { font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
margin: 0 0 4px;
color: var(--text-main);
line-height: 1.1;
}
.page-subtitle {
font-size: 15px;
color: var(--text-muted);
margin: 0;
}
.head-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
&.error { color: #dc2626; }
}
.last-update {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
display: flex; align-items: center; gap: 6px;
i { color: #10b981; }
}
/* ========================================================== */
/* HERO KPIS */
/* ========================================================== */
.hero-grid {
display: grid; display: grid;
grid-template-columns: 360px 1fr; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px; gap: 16px;
padding: 12px; margin-bottom: 32px;
@media (max-width: 900px) { grid-template-columns: 1fr; }
} }
.pie-wrap { .hero-card {
position: relative; background: rgba(255, 255, 255, 0.8);
height: 260px; backdrop-filter: blur(12px);
padding: 6px; border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
canvas { &:hover {
width: 100% !important; transform: translateY(-3px);
height: 100% !important; background: #fff;
display: block; box-shadow: var(--shadow-card);
border-color: rgba(227, 61, 207, 0.2);
} }
} }
.status-metrics { .hero-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), #9d2ec5);
color: #fff;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); place-items: center;
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: var(--chart-pink);
}
.dot {
width: 10px;
height: 10px;
border-radius: 99px;
flex: 0 0 auto;
}
/* ✅ DOTS COM CORES "PADRÃO DE DASHBOARD" */
.dot.d1 { background: var(--chart-pink); }
.dot.d2 { background: var(--chart-blue); }
.dot.d3 { background: var(--chart-pink-dark); }
.dot.d4 { background: var(--chart-violet); }
.dot.d5 { background: var(--chart-pink-soft); }
.dot.d6 { background: var(--chart-blue-soft); }
.meta .k {
font-weight: 900;
font-size: 12px;
color: rgba(17,18,20,0.65);
}
.meta .v {
font-weight: 900;
font-size: 18px; font-size: 18px;
color: rgba(17,18,20,0.92); box-shadow: 0 4px 10px rgba(227, 61, 207, 0.3);
}
.hero-data {
display: flex; flex-direction: column;
}
.hero-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.hero-value {
font-size: 24px;
font-weight: 800;
color: var(--text-main);
margin-top: 2px; margin-top: 2px;
} }
/* Charts */ .hero-hint {
.charts-grid { font-size: 11px;
display: grid; font-weight: 600;
grid-template-columns: repeat(2, minmax(0, 1fr)); color: var(--text-muted);
gap: 12px; opacity: 0.8;
margin-top: 10px; /* ✅ era 12px */ margin-top: 4px;
@media (max-width: 900px) { grid-template-columns: 1fr; }
} }
.charts-vigencia { /* ========================================================== */
margin-top: 10px; /* ✅ era 12px */ /* CARDS MODERNOS (Container Genérico) */
} /* ========================================================== */
.card-modern {
.chart-wrap { background: #fff;
position: relative; border-radius: var(--radius-xl);
height: 320px; border: 1px solid rgba(0,0,0,0.04);
padding: 12px 12px 16px; box-shadow: var(--shadow-card);
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
canvas { &:hover {
width: 100% !important; box-shadow: var(--shadow-hover);
height: 100% !important;
display: block;
} }
} }
/* Table */ .card-header-clean {
.table-wrap { padding: 20px 24px;
padding: 12px 12px 16px; border-bottom: 1px solid rgba(0,0,0,0.04);
overflow-x: auto; display: flex;
background: rgba(255, 255, 255, 0.7); align-items: center;
border-radius: 16px; gap: 16px;
border: 1px solid rgba(0, 0, 0, 0.06); background: rgba(250, 250, 252, 0.5);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.tablex { .header-icon {
width: 100%; width: 36px; height: 36px;
border-collapse: separate; border-radius: 10px;
border-spacing: 0 8px; display: grid; place-items: center;
min-width: 720px; font-size: 16px;
}
.tablex th, &.brand { background: var(--brand-soft); color: var(--brand); }
.tablex td { &.blue { background: var(--blue-soft); color: var(--blue); }
padding: 12px 12px; &.purple { background: rgba(106, 85, 255, 0.1); color: var(--chart-purple); }
font-weight: 700; &.warning { background: rgba(245, 158, 11, 0.1); color: #d97706; }
color: rgba(17, 18, 20, 0.8); }
text-align: left;
white-space: nowrap;
}
.tablex th { h3 {
color: rgba(17, 18, 20, 0.65); margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--text-main);
}
p {
margin: 2px 0 0;
font-size: 12px; font-size: 12px;
text-transform: uppercase; color: var(--text-muted);
letter-spacing: 0.04em; font-weight: 500;
padding-bottom: 6px; }
} }
.tablex tbody tr { /* ========================================================== */
/* SEÇÃO 1: STATUS SPLIT */
/* ========================================================== */
.dashboard-section {
margin-bottom: 24px;
}
.context-title {
margin: 4px 0 14px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
color: var(--text-main);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
}
.section-top-row {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 18px;
@media(max-width: 1120px) { grid-template-columns: 1fr; }
}
.section-bottom-row {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
@media(max-width: 900px) { grid-template-columns: 1fr; }
}
.card-body-split {
padding: 14px 16px 12px;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
gap: 12px;
@media(max-width: 700px) { grid-template-columns: 1fr; }
}
.chart-wrapper-pie {
position: relative;
height: 184px;
width: 100%;
}
.status-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
background: #f8fafc;
border-radius: 10px;
font-size: 12px;
.dot { width: 8px; height: 8px; border-radius: 50%; margin-right: 10px; }
.d-active { background: var(--chart-blue); box-shadow: 0 0 0 2px rgba(3, 15, 170, 0.2); }
.d-reserve { background: var(--chart-pink-soft); }
.d-blocked { background: var(--chart-dark); }
.d-blocked-soft { background: var(--chart-purple); }
.lbl { font-weight: 600; color: var(--text-muted); margin-right: auto; }
.val { font-weight: 800; color: var(--text-main); font-size: 13px; }
&.total-row {
background: var(--brand-soft);
border: 1px solid rgba(227, 61, 207, 0.1);
margin-top: 4px;
.lbl { color: var(--brand); text-transform: uppercase; font-size: 11px; font-weight: 800; }
.val { color: var(--brand); font-size: 16px; }
}
}
.chart-wrapper-bar {
padding: 16px 20px 20px;
height: 220px;
position: relative;
&.compact { height: 180px; }
&.compact-half { height: 200px; }
}
.card-adicionais .card-body-adicionais {
padding: 14px 16px 12px;
display: grid;
grid-template-columns: 0.95fr 1.05fr;
gap: 12px;
align-items: center;
@media(max-width: 700px) { grid-template-columns: 1fr; }
}
.chart-wrapper-pie-sm {
position: relative;
height: 186px;
width: 100%;
}
.compare-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.compare-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.05);
font-size: 12px;
.dot { width: 8px; height: 8px; border-radius: 50%; }
.d-com-add { background: var(--chart-purple); box-shadow: 0 0 0 2px rgba(106, 85, 255, 0.2); }
.d-sem-add { background: var(--brand); box-shadow: 0 0 0 2px rgba(227, 61, 207, 0.18); }
.lbl { color: var(--text-muted); font-weight: 700; }
.val { color: var(--text-main); font-weight: 800; }
.pct {
color: var(--blue);
font-weight: 800;
background: rgba(3, 15, 170, 0.1);
border-radius: 999px;
padding: 2px 8px;
}
&.total-row {
grid-template-columns: 1fr auto;
.lbl { color: var(--brand); text-transform: uppercase; font-size: 11px; letter-spacing: 0.03em; }
.val { color: var(--brand); font-size: 14px; }
}
}
/* ========================================================== */
/* SEÇÃO 2: ANALYTICS INTEGRADO (TOOLBAR) */
/* ========================================================== */
.toolbar-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(0,0,0,0.06);
background: #fff; background: #fff;
box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06); display: flex;
transition: transform 160ms ease, box-shadow 160ms ease; justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
} }
.tablex tbody tr:hover { .title-group {
transform: translateY(-1px); display: flex;
box-shadow: 0 12px 22px rgba(17, 18, 20, 0.12); align-items: flex-start;
gap: 12px;
i { font-size: 20px; margin-top: 2px; }
h3 { margin: 0; font-size: 16px; font-weight: 800; }
p { margin: 2px 0 0; font-size: 12px; color: var(--text-muted); }
} }
.tablex tbody td { .toolbar-controls {
border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex;
border-bottom: 1px solid rgba(17, 18, 20, 0.06); align-items: center;
background: rgba(255, 255, 255, 0.96); gap: 16px;
flex-wrap: wrap;
} }
.tablex tbody td:first-child { .control-group {
border-left: 1px solid rgba(17, 18, 20, 0.06); display: flex;
border-top-left-radius: 12px; align-items: center;
border-bottom-left-radius: 12px; gap: 8px;
label { font-size: 12px; font-weight: 700; color: var(--text-muted); white-space: nowrap; }
} }
.tablex tbody td:last-child { .form-select-sm {
border-right: 1px solid rgba(17, 18, 20, 0.06); padding: 4px 28px 4px 10px;
border-top-right-radius: 12px; border-radius: 8px;
border-bottom-right-radius: 12px; border: 1px solid rgba(0,0,0,0.1);
} font-size: 12px;
.tablex tbody tr:nth-child(even) td {
background: rgba(248, 249, 255, 0.9);
}
.muted {
color: rgba(17, 18, 20, 0.55);
font-weight: 700; font-weight: 700;
background-color: #f8fafc;
cursor: pointer;
&:focus { border-color: var(--brand); outline: none; }
} }
.cell-strong { .divider-v {
font-weight: 900; width: 1px; height: 24px; background: rgba(0,0,0,0.1);
color: rgba(17, 18, 20, 0.92);
} }
.cell-clip { .btn-link {
max-width: 240px; font-size: 12px;
overflow: hidden; font-weight: 700;
text-overflow: ellipsis; color: var(--brand);
text-decoration: none;
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 12px;
border-radius: 8px;
background: var(--brand-soft);
transition: all 0.2s;
&:hover { background: rgba(227, 61, 207, 0.15); transform: translateX(2px); }
} }
.card-body-grid {
padding: 20px;
position: relative;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
@media(max-width: 820px) { grid-template-columns: 1fr; }
}
.mini-chart-card {
background: #fdfdfd;
border: 1px solid rgba(0,0,0,0.05);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
h6 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
text-align: center;
}
}
.mini-metric-card {
.metric-stack {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 2px 2px;
}
.metric-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.06);
span {
font-size: 12px;
color: var(--text-muted);
font-weight: 700;
}
strong {
font-size: 13px;
color: var(--text-main);
font-weight: 800;
text-align: right;
}
}
}
.chart-area {
position: relative;
height: 280px;
width: 100%;
}
.loading-overlay {
position: absolute; inset: 0; background: rgba(255,255,255,0.8); z-index: 10;
display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px);
}
.error-state {
padding: 40px; text-align: center; color: #d97706; font-weight: 600;
display: flex; flex-direction: column; align-items: center; gap: 8px;
i { font-size: 24px; }
}
/* ========================================================== */
/* SEÇÃO 3: GRIDS FINAIS */
/* ========================================================== */
.grid-thirds {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
@media(max-width: 1000px) { grid-template-columns: 1fr; }
}
.grid-halves {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
@media(max-width: 800px) { grid-template-columns: 1fr; }
}
.grid-triples {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
@media(max-width: 1100px) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media(max-width: 800px) { grid-template-columns: 1fr; }
}
.history-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
@media(max-width: 1080px) { grid-template-columns: 1fr; }
}
/* Utils */
.text-brand { color: var(--brand); }
.text-brand-dark { color: #b832a8; }
.full-width { width: 100%; }

File diff suppressed because it is too large Load Diff

View File

@ -168,7 +168,7 @@
<input <input
class="form-control" class="form-control"
placeholder="Pesquisar por cliente, aparelho, forma de pagamento..." placeholder="Pesquisar..."
[(ngModel)]="searchTerm" [(ngModel)]="searchTerm"
(ngModelChange)="onSearch()" /> (ngModelChange)="onSearch()" />
@ -244,7 +244,7 @@
<th colspan="2" class="th-block th-vivo">VIVO</th> <th colspan="2" class="th-block th-vivo">VIVO</th>
<th colspan="2" class="th-block th-line">LINE MÓVEL</th> <th colspan="2" class="th-block th-line">LINE MÓVEL</th>
<th rowspan="2">AÇÕES</th> <th rowspan="2" class="actions-col">AÇÕES</th>
</tr> </tr>
<tr class="thead-sub"> <tr class="thead-sub">
@ -286,6 +286,8 @@
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button> <button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -325,9 +327,9 @@
</section> </section>
<!-- MODAIS --> <!-- MODAIS -->
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()"></div> <div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()"></div>
<div class="modal-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()"> <div class="modal-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()">
<!-- DETAIL MODAL --> <!-- DETAIL MODAL -->
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()"> <div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
@ -446,6 +448,127 @@
</ng-template> </ng-template>
</div> </div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (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 Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipo">
<option value="PF">PF</option>
<option value="PJ">PJ</option>
</select>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Qtd Linhas</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.qtdLinhas" />
</div>
<div class="form-field">
<label>Aparelho</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelho" />
</div>
<div class="form-field span-2">
<label>Forma de Pagamento</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.formaPagamento" />
</div>
</div>
</div>
</details>
<details open class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Faturamento Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Vivo</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaVivo" />
</div>
<div class="form-field">
<label>Valor Vivo (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoVivo" />
</div>
</div>
</div>
</details>
<details open class="detail-box line-border">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Faturamento Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Franquia Line</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaLine" />
</div>
<div class="form-field">
<label>Valor Line (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoLine" />
</div>
<div class="form-field span-2">
<label>Lucro (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.lucro" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span> Remover Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma a exclusão do registro <strong>{{ deleteTarget?.cliente }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div> </div>

View File

@ -174,6 +174,22 @@
font-weight: 700; font-weight: 700;
} }
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(3, 15, 170, 0.24);
color: var(--blue);
transition: all 0.2s ease;
&:hover {
background: #fff;
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
}
}
/* FILTERS */ /* FILTERS */
.filters-row { .filters-row {
display: flex; display: flex;
@ -322,7 +338,7 @@
} }
.search-group { .search-group {
max-width: 360px; max-width: 270px;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@ -671,7 +687,16 @@
.th-item .th-content { justify-content: center; } .th-item .th-content { justify-content: center; }
/* ACTIONS */ /* ACTIONS */
.action-group { display: flex; justify-content: center; gap: 6px; } .actions-col { min-width: 152px; }
.action-group {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.btn-icon { .btn-icon {
width: 32px; width: 32px;
@ -687,6 +712,8 @@
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.success:hover { color: var(--success-text); background: var(--success-bg); } &.success:hover { color: var(--success-text); background: var(--success-bg); }
&.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
} }
/* FOOTER */ /* FOOTER */
@ -753,8 +780,9 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(900px, 100%); width: min(850px, 100%);
max-height: 90vh; max-height: 90vh;
min-height: 0;
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@ -781,27 +809,148 @@
} }
.icon-bg { .icon-bg {
width: 34px; width: 32px;
height: 34px; height: 32px;
border-radius: 12px; border-radius: 10px;
background: rgba(3, 15, 170, 0.12); background: rgba(3, 15, 170, 0.1);
color: var(--blue); color: var(--blue);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.7), 0 8px 18px rgba(3, 15, 170, 0.18); font-size: 16px;
font-size: 1rem;
&.success { background: var(--success-bg); color: var(--success-text); } &.success { background: var(--success-bg); color: var(--success-text); }
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
} }
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } }
} }
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
}
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.edit-sections { display: grid; gap: 12px; }
.edit-sections details.detail-box {
border: 1px solid rgba(17, 18, 20, 0.08);
box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06);
overflow: hidden;
}
summary.box-header {
padding: 10px 16px;
font-size: 0.76rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(17, 18, 20, 0.08);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
display: flex;
align-items: center;
cursor: pointer;
list-style: none;
user-select: none;
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
&::-webkit-details-marker { display: none; }
}
.transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.header-vivo {
color: #b91f9b;
background: linear-gradient(135deg, rgba(227, 61, 207, 0.14), rgba(248, 250, 252, 0.96));
}
.header-line {
color: var(--blue);
background: linear-gradient(135deg, rgba(3, 15, 170, 0.1), rgba(248, 250, 252, 0.96));
}
.vivo-border { border-top: 3px solid rgba(227, 61, 207, 0.45); }
.line-border { border-top: 3px solid rgba(3, 15, 170, 0.45); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
&.span-2 { grid-column: span 2; }
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.64);
}
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17, 18, 20, 0.15);
background-color: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.38); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}
/* detalhes e comparativo (mantidos) */ /* detalhes e comparativo (mantidos) */
.details-dashboard { .details-dashboard {
display: grid; display: grid;

View File

@ -20,8 +20,10 @@ import {
BillingSortBy, BillingSortBy,
SortDir, SortDir,
TipoCliente, TipoCliente,
TipoFiltro TipoFiltro,
BillingUpdateRequest
} from '../../services/billing'; } from '../../services/billing';
import { AuthService } from '../../services/auth.service';
interface BillingClientGroup { interface BillingClientGroup {
cliente: string; cliente: string;
@ -48,7 +50,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private billing: BillingService, private billing: BillingService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef,
private authService: AuthService
) {} ) {}
loading = false; loading = false;
@ -92,6 +95,14 @@ export class Faturamento implements AfterViewInit, OnDestroy {
compareOpen = false; compareOpen = false;
detailData: BillingItem | null = null; detailData: BillingItem | null = null;
compareData: BillingItem | null = null; compareData: BillingItem | null = null;
editOpen = false;
editSaving = false;
editModel: BillingItem | null = null;
editingId: string | null = null;
deleteOpen = false;
deleteTarget: BillingItem | null = null;
isAdmin = false;
private searchTimer: any = null; private searchTimer: any = null;
@ -120,20 +131,21 @@ export class Faturamento implements AfterViewInit, OnDestroy {
} }
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onDocumentKeydown(ev: KeyboardEvent) { onDocumentKeydown(ev: Event) {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
if (ev.key === 'Escape') { const keyboard = ev as KeyboardEvent;
if (keyboard.key === 'Escape') {
if (this.anyModalOpen()) { if (this.anyModalOpen()) {
ev.preventDefault(); keyboard.preventDefault();
ev.stopPropagation(); keyboard.stopPropagation();
this.closeAllModals(); this.closeAllModals();
return; return;
} }
if (this.showClientMenu) { if (this.showClientMenu) {
this.showClientMenu = false; this.showClientMenu = false;
ev.stopPropagation(); keyboard.stopPropagation();
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
} }
@ -147,6 +159,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
this.isAdmin = this.authService.hasRole('admin');
setTimeout(() => { setTimeout(() => {
this.refreshData(true); this.refreshData(true);
@ -165,7 +178,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// Helpers // Helpers
// -------------------------- // --------------------------
private anyModalOpen(): boolean { private anyModalOpen(): boolean {
return !!(this.detailOpen || this.compareOpen); return !!(this.detailOpen || this.compareOpen || this.editOpen || this.deleteOpen);
} }
closeAllModals() { closeAllModals() {
@ -173,6 +186,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
this.compareOpen = false; this.compareOpen = false;
this.detailData = null; this.detailData = null;
this.compareData = null; this.compareData = null;
this.editOpen = false;
this.editModel = null;
this.editingId = null;
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
@ -204,6 +222,24 @@ export class Faturamento implements AfterViewInit, OnDestroy {
.replace(/[\u0300-\u036f]/g, ''); .replace(/[\u0300-\u036f]/g, '');
} }
private buildGlobalSearchBlob(row: BillingItem): string {
const parts = [
row.tipo,
row.item,
row.cliente,
row.qtdLinhas,
row.franquiaVivo,
row.valorContratoVivo,
row.franquiaLine,
row.valorContratoLine,
row.lucro,
row.aparelho,
row.formaPagamento,
];
return this.normalizeText(parts.join(' '));
}
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean { private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
if (filtro === 'ALL') return true; if (filtro === 'ALL') return true;
@ -475,14 +511,9 @@ export class Faturamento implements AfterViewInit, OnDestroy {
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente))); arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
} }
const term = (this.searchTerm ?? '').trim().toLowerCase(); const term = this.normalizeText(this.searchTerm);
if (term) { if (term) {
arr = arr.filter((r) => { arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
const cliente = (r.cliente ?? '').toLowerCase();
const aparelho = (r.aparelho ?? '').toLowerCase();
const forma = (r.formaPagamento ?? '').toLowerCase();
return cliente.includes(term) || aparelho.includes(term) || forma.includes(term);
});
} }
// KPIs // KPIs
@ -617,13 +648,104 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// -------------------------- // --------------------------
onDetalhes(r: BillingItem) { onDetalhes(r: BillingItem) {
this.detailOpen = true; this.detailOpen = true;
this.detailData = null;
this.billing.getById(r.id).subscribe({
next: (data) => {
this.detailData = data ?? r;
this.cdr.detectChanges();
},
error: () => {
this.detailData = r; this.detailData = r;
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
});
}
onComparativo(r: BillingItem) { onComparativo(r: BillingItem) {
this.compareOpen = true; this.compareOpen = true;
this.compareData = r; this.compareData = r;
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
onEditar(r: BillingItem) {
if (!this.isAdmin) return;
this.editingId = r.id;
this.editModel = { ...r };
this.editOpen = true;
this.cdr.detectChanges();
}
closeEdit() {
this.editOpen = false;
this.editModel = null;
this.editingId = null;
this.cdr.detectChanges();
}
onDelete(r: BillingItem) {
if (!this.isAdmin) return;
this.deleteTarget = r;
this.deleteOpen = true;
this.cdr.detectChanges();
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges();
}
confirmDelete() {
if (!this.deleteTarget) return;
const id = this.deleteTarget.id;
this.billing.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.refreshData(true);
this.cdr.detectChanges();
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges();
}
});
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const payload: BillingUpdateRequest = {
tipo: this.editModel.tipo,
item: this.toNullableNumber(this.editModel.item) as number | null,
cliente: (this.editModel.cliente ?? '').toString(),
qtdLinhas: this.toNullableNumber(this.editModel.qtdLinhas),
franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo),
valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo),
franquiaLine: this.toNullableNumber(this.editModel.franquiaLine),
valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine),
lucro: this.toNullableNumber(this.editModel.lucro),
aparelho: this.editModel.aparelho ?? null,
formaPagamento: this.editModel.formaPagamento ?? null
};
this.billing.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.refreshData(true);
},
error: () => {
this.editSaving = false;
}
});
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
} }

View File

@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<section class="geral-page" (click)="closeClientDropdown()"> <section class="geral-page" (click)="closeFilterDropdowns()">
<span class="page-blob blob-1" aria-hidden="true"></span> <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-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span> <span class="page-blob blob-3" aria-hidden="true"></span>
@ -35,6 +35,7 @@
<button <button
type="button" type="button"
class="btn btn-glass btn-sm" class="btn btn-glass btn-sm"
*ngIf="isAdmin"
(click)="onImportExcel()" (click)="onImportExcel()"
[disabled]="loading"> [disabled]="loading">
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel <i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
@ -121,40 +122,125 @@
</div> </div>
</div> </div>
</div> </div>
<div class="additional-filter-wrap" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-client-filter btn-additional-filter"
[class.has-selection]="hasAdditionalFiltersApplied"
(click)="toggleAdditionalMenu()"
[disabled]="loading">
<ng-container *ngIf="!hasAdditionalFiltersApplied">
<i class="bi bi-sliders2-vertical me-2"></i>
<span>Adicionais</span>
<i class="bi bi-chevron-down ms-2 small"></i>
</ng-container>
<ng-container *ngIf="hasAdditionalFiltersApplied">
<div class="chips-container">
<span class="client-chip">
{{ additionalModeLabel }}
</span>
<span *ngFor="let label of additionalSelectedLabels" class="client-chip">
{{ label }}
</span>
</div>
<i class="bi bi-chevron-down ms-1 small text-muted"></i>
</ng-container>
</button>
<div class="client-dropdown additional-dropdown" *ngIf="showAdditionalMenu">
<div class="additional-dropdown-section">
<div class="additional-dropdown-title">Modo</div>
<div class="additional-mode-tabs">
<button
type="button"
class="additional-mode-btn"
[class.active]="additionalMode === 'ALL'"
(click)="setAdditionalMode('ALL')"
[disabled]="loading">
Todos os adicionais
</button>
<button
type="button"
class="additional-mode-btn"
[class.active]="additionalMode === 'WITH'"
(click)="setAdditionalMode('WITH')"
[disabled]="loading">
Com adicionais
</button>
<button
type="button"
class="additional-mode-btn"
[class.active]="additionalMode === 'WITHOUT'"
(click)="setAdditionalMode('WITHOUT')"
[disabled]="loading">
Sem adicionais
</button>
</div>
</div>
<div class="additional-dropdown-section">
<div class="additional-dropdown-title">Serviços</div>
<div class="additional-services-chips">
<button
type="button"
class="additional-chip-btn"
*ngFor="let svc of additionalServiceOptions"
[class.active]="isAdditionalServiceSelected(svc.key)"
(click)="toggleAdditionalService(svc.key)"
[disabled]="loading">
{{ svc.label }}
</button>
</div>
</div>
<div class="additional-dropdown-footer">
<button
type="button"
class="additional-chip-btn clear"
(click)="clearAdditionalFilters()"
[disabled]="loading">
Limpar filtros adicionais
</button>
</div>
</div>
</div>
</div> </div>
<!-- KPIs --> <!-- KPIs -->
<div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode"> <div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode">
<div class="kpi"> <div class="kpi">
<span class="lbl">Total Clientes</span> <span class="lbl">Total Clientes</span>
<span class="val"> <span class="val val-loading" *ngIf="isKpiLoading">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span> <span class="spinner-border spinner-border-sm text-brand"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
</span> </span>
<span class="val" *ngIf="!isKpiLoading">{{ kpiTotalClientes || 0 }}</span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl">Total Linhas</span> <span class="lbl">Total Linhas</span>
<span class="val"> <span class="val val-loading" *ngIf="isKpiLoading">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span> <span class="spinner-border spinner-border-sm text-brand"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
</span> </span>
<span class="val" *ngIf="!isKpiLoading">{{ kpiTotalLinhas || 0 }}</span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl text-success">Ativas</span> <span class="lbl text-success">Ativas</span>
<span class="val"> <span class="val val-loading" *ngIf="isKpiLoading">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span> <span class="spinner-border spinner-border-sm text-brand"></span>
<span *ngIf="!loadingKpis">{{ kpiAtivas || 0 }}</span>
</span> </span>
<span class="val" *ngIf="!isKpiLoading">{{ kpiAtivas || 0 }}</span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="lbl text-danger">Bloqueadas</span> <span class="lbl text-danger">Bloqueadas</span>
<span class="val"> <span class="val val-loading" *ngIf="isKpiLoading">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span> <span class="spinner-border spinner-border-sm text-brand"></span>
<span *ngIf="!loadingKpis">{{ kpiBloqueadas || 0 }}</span>
</span> </span>
<span class="val" *ngIf="!isKpiLoading">{{ kpiBloqueadas || 0 }}</span>
</div> </div>
</div> </div>
@ -204,10 +290,10 @@
<div class="group-header" (click)="toggleGroup(group.cliente)"> <div class="group-header" (click)="toggleGroup(group.cliente)">
<div class="group-info"> <div class="group-info">
<h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6> <h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6>
<div class="group-badges"> <div class="group-tags">
<span class="badge-pill total">{{ group.totalLinhas }} Linhas</span> <span class="tag-pill">{{ group.totalLinhas }} linhas</span>
<span class="badge-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} Ativas</span> <span class="tag-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} ativas</span>
<span class="badge-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} Bloq.</span> <span class="tag-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} bloqueadas</span>
</div> </div>
</div> </div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div> <div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
@ -253,7 +339,7 @@
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -370,7 +456,7 @@
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -428,9 +514,8 @@
<div <div
*ngIf="createOpen" *ngIf="createOpen"
#createModal #createModal
class="modal-card modal-lg" class="modal-card modal-lg modal-create"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
style="width: 1100px; max-width: 95vw;"
> >
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title">
@ -490,9 +575,28 @@
<input class="form-control form-control-sm bg-light fst-italic text-muted" value="Gerado ao Salvar" readonly title="O ID será gerado automaticamente pelo sistema" /> <input class="form-control form-control-sm bg-light fst-italic text-muted" value="Gerado ao Salvar" readonly title="O ID será gerado automaticamente pelo sistema" />
</div> </div>
<div class="form-field">
<label>Empresa (Conta) <span class="text-danger">*</span></label>
<app-select
class="form-select"
size="sm"
[options]="contaEmpresaOptions"
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
[(ngModel)]="createModel.contaEmpresa"
(ngModelChange)="onContaEmpresaChange(false)"
></app-select>
</div>
<div class="form-field"> <div class="form-field">
<label>Conta <span class="text-danger">*</span></label> <label>Conta <span class="text-danger">*</span></label>
<app-select class="form-select" size="sm" [options]="contaOptions" [(ngModel)]="createModel.conta"></app-select> <app-select
class="form-select"
size="sm"
[options]="contaOptionsForCreate"
[disabled]="!createModel.contaEmpresa"
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="createModel.conta"
></app-select>
</div> </div>
<div class="form-field"> <div class="form-field">
@ -505,6 +609,11 @@
<input class="form-control form-control-sm" [(ngModel)]="createModel.chip" /> <input class="form-control form-control-sm" [(ngModel)]="createModel.chip" />
</div> </div>
<div class="form-field">
<label>Tipo de Chip</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" />
</div>
<div class="form-field span-2"> <div class="form-field span-2">
<label>Usuário da Linha</label> <label>Usuário da Linha</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /> <input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" />
@ -513,7 +622,7 @@
</div> </div>
</details> </details>
<details class="detail-box mt-3"> <details class="detail-box mt-3" open>
<summary class="box-header"> <summary class="box-header">
<span><i class="bi bi-sliders me-2"></i> Gestão</span> <span><i class="bi bi-sliders me-2"></i> Gestão</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i> <i class="bi bi-chevron-down ms-auto transition-icon"></i>
@ -551,7 +660,7 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-field span-2"> <div class="form-field span-2">
<label>Plano Contrato <span class="text-danger">*</span></label> <label>Plano Contrato <span class="text-danger">*</span></label>
<app-select class="form-select" size="sm" [options]="planOptions" [(ngModel)]="createModel.planoContrato"></app-select> <app-select class="form-select" size="sm" [options]="planOptions" [(ngModel)]="createModel.planoContrato" (ngModelChange)="onPlanoChange(false)"></app-select>
</div> </div>
<div class="form-field"> <div class="form-field">
@ -572,7 +681,7 @@
</div> </div>
</details> </details>
<details class="detail-box mt-3"> <details class="detail-box mt-3" open>
<summary class="box-header"> <summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Datas Importantes</span> <span><i class="bi bi-calendar-event me-2"></i> Datas Importantes</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i> <i class="bi bi-chevron-down ms-auto transition-icon"></i>
@ -594,6 +703,16 @@
<label>Data de Bloqueio</label> <label>Data de Bloqueio</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataBloqueio" /> <input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataBloqueio" />
</div> </div>
<div class="form-field span-2">
<label>Dt. Efetivação Serviço <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtEfetivacaoServico" required />
</div>
<div class="form-field span-2">
<label>Dt. Término Fidelização <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtTerminoFidelizacao" required />
</div>
</div> </div>
</div> </div>
</details> </details>
@ -615,6 +734,7 @@
<div class="form-field"><label>News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoNewsPlus" (change)="onFinancialChange(false)" /></div> <div class="form-field"><label>News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoNewsPlus" (change)="onFinancialChange(false)" /></div>
<div class="form-field"><label>Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoTravelMundo" (change)="onFinancialChange(false)" /></div> <div class="form-field"><label>Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoTravelMundo" (change)="onFinancialChange(false)" /></div>
<div class="form-field"><label>Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoGestaoDispositivo" (change)="onFinancialChange(false)" /></div> <div class="form-field"><label>Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoGestaoDispositivo" (change)="onFinancialChange(false)" /></div>
<div class="form-field"><label>Vivo Sync</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoSync" (change)="onFinancialChange(false)" /></div>
<div class="form-field"><label class="text-vivo fw-bold">Total Vivo (Auto)</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="createModel.valorContratoVivo" readonly /></div> <div class="form-field"><label class="text-vivo fw-bold">Total Vivo (Auto)</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="createModel.valorContratoVivo" readonly /></div>
</div> </div>
</div> </div>
@ -698,6 +818,10 @@
<span class="lbl">Chip (ICCID)</span> <span class="lbl">Chip (ICCID)</span>
<span class="val small-text">{{ detailData.chip || '-' }}</span> <span class="val small-text">{{ detailData.chip || '-' }}</span>
</div> </div>
<div class="info-item">
<span class="lbl">Tipo de Chip</span>
<span class="val">{{ detailData.tipoDeChip || '-' }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -744,6 +868,14 @@
<span class="lbl text-danger">Data Bloqueio</span> <span class="lbl text-danger">Data Bloqueio</span>
<span class="val">{{ formatDateBr(detailData.dataBloqueio) }}</span> <span class="val">{{ formatDateBr(detailData.dataBloqueio) }}</span>
</div> </div>
<div class="info-item">
<span class="lbl">Efetivação Serviço</span>
<span class="val">{{ formatDateBr(detailData.dtEfetivacaoServico) }}</span>
</div>
<div class="info-item">
<span class="lbl">Término Fidelização</span>
<span class="val">{{ formatDateBr(detailData.dtTerminoFidelizacao) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -817,6 +949,7 @@
<div class="row-item"><span>Vivo News+</span> <strong>{{ formatMoney(financeData.vivoNewsPlus) }}</strong></div> <div class="row-item"><span>Vivo News+</span> <strong>{{ formatMoney(financeData.vivoNewsPlus) }}</strong></div>
<div class="row-item"><span>Travel Mundo</span> <strong>{{ formatMoney(financeData.vivoTravelMundo) }}</strong></div> <div class="row-item"><span>Travel Mundo</span> <strong>{{ formatMoney(financeData.vivoTravelMundo) }}</strong></div>
<div class="row-item"><span>Gestão Disp.</span> <strong>{{ formatMoney(financeData.vivoGestaoDispositivo) }}</strong></div> <div class="row-item"><span>Gestão Disp.</span> <strong>{{ formatMoney(financeData.vivoGestaoDispositivo) }}</strong></div>
<div class="row-item"><span>Vivo Sync</span> <strong>{{ formatMoney(financeData.vivoSync) }}</strong></div>
<div class="divider"></div> <div class="divider"></div>
<div class="row-item total"><span>Total Vivo</span> <strong>{{ formatMoney(financeData.valorContratoVivo) }}</strong></div> <div class="row-item total"><span>Total Vivo</span> <strong>{{ formatMoney(financeData.valorContratoVivo) }}</strong></div>
</div> </div>
@ -882,9 +1015,11 @@
<div class="box-body"> <div class="box-body">
<div class="form-grid"> <div class="form-grid">
<div class="form-field"><label>Item</label><input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" disabled title="O ID não pode ser alterado" /></div> <div class="form-field"><label>Item</label><input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" disabled title="O ID não pode ser alterado" /></div>
<div class="form-field"><label>Empresa (Conta)</label><app-select class="form-select" size="sm" [options]="contaEmpresaOptionsForEdit" [(ngModel)]="editModel.contaEmpresa" (ngModelChange)="onContaEmpresaChange(true)"></app-select></div>
<div class="form-field"><label>Conta</label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta"></app-select></div> <div class="form-field"><label>Conta</label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta"></app-select></div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div> <div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div> <div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div>
<div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div>
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div> <div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div> <div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
</div> </div>
@ -895,7 +1030,7 @@
<summary class="box-header"><span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Plano</span><i class="bi bi-chevron-down ms-auto transition-icon"></i></summary> <summary class="box-header"><span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Plano</span><i class="bi bi-chevron-down ms-auto transition-icon"></i></summary>
<div class="box-body"> <div class="box-body">
<div class="form-grid"> <div class="form-grid">
<div class="form-field span-2"><label>Plano Contrato</label><app-select class="form-select" size="sm" [options]="planOptionsForEdit" [(ngModel)]="editModel.planoContrato"></app-select></div> <div class="form-field span-2"><label>Plano Contrato</label><app-select class="form-select" size="sm" [options]="planOptionsForEdit" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onPlanoChange(true)"></app-select></div>
<div class="form-field"><label>Venc. da Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.vencConta" /></div> <div class="form-field"><label>Venc. da Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.vencConta" /></div>
<div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="editModel.modalidade" /></div> <div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="editModel.modalidade" /></div>
</div> </div>
@ -910,6 +1045,8 @@
<div class="form-field"><label>Data do Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataBloqueio" /></div> <div class="form-field"><label>Data do Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataBloqueio" /></div>
<div class="form-field"><label>Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaOpera" /></div> <div class="form-field"><label>Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaOpera" /></div>
<div class="form-field"><label>Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaCliente" /></div> <div class="form-field"><label>Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaCliente" /></div>
<div class="form-field"><label>Dt. Efetivação Serviço</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dtEfetivacaoServico" /></div>
<div class="form-field"><label>Dt. Término Fidelização</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dtTerminoFidelizacao" /></div>
<div class="form-field"><label>Skil</label><app-select class="form-select" size="sm" [options]="skilOptionsForEdit" [(ngModel)]="editModel.skil"></app-select></div> <div class="form-field"><label>Skil</label><app-select class="form-select" size="sm" [options]="skilOptionsForEdit" [(ngModel)]="editModel.skil"></app-select></div>
<div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cedente" /></div> <div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cedente" /></div>
<div class="form-field span-2"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="editModel.solicitante" /></div> <div class="form-field span-2"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="editModel.solicitante" /></div>
@ -928,6 +1065,7 @@
<div class="form-field"><label>Vivo News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoNewsPlus" (change)="onFinancialChange(true)" /></div> <div class="form-field"><label>Vivo News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoNewsPlus" (change)="onFinancialChange(true)" /></div>
<div class="form-field"><label>Vivo Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoTravelMundo" (change)="onFinancialChange(true)" /></div> <div class="form-field"><label>Vivo Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoTravelMundo" (change)="onFinancialChange(true)" /></div>
<div class="form-field"><label>Vivo Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoGestaoDispositivo" (change)="onFinancialChange(true)" /></div> <div class="form-field"><label>Vivo Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoGestaoDispositivo" (change)="onFinancialChange(true)" /></div>
<div class="form-field"><label>Vivo Sync</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoSync" (change)="onFinancialChange(true)" /></div>
<div class="form-field"><label class="text-vivo fw-bold">Valor Contrato Vivo</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="editModel.valorContratoVivo" readonly /></div> <div class="form-field"><label class="text-vivo fw-bold">Valor Contrato Vivo</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="editModel.valorContratoVivo" readonly /></div>
</div> </div>
</div> </div>
@ -963,4 +1101,3 @@
</div> </div>
</div> </div>
</div> </div>

View File

@ -41,13 +41,13 @@
/* 2. LAYOUT DA PÁGINA (Vertical Destravado) */ /* 2. LAYOUT DA PÁGINA (Vertical Destravado) */
/* ========================================================== */ /* ========================================================== */
.geral-page { .geral-page {
min-height: 100vh; min-height: 100dvh;
padding: 0 12px var(--page-bottom-gap); padding: var(--page-top-gap) 12px var(--page-bottom-gap);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
position: relative; position: relative;
overflow-y: auto; /* Scroll na janela */ overflow: visible;
background: background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
@ -80,7 +80,7 @@
max-width: 1100px; /* Largura controlada */ max-width: 1100px; /* Largura controlada */
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-top: var(--page-top-gap); margin-top: 0;
margin-bottom: var(--page-bottom-gap); margin-bottom: var(--page-bottom-gap);
margin-left: auto; margin-right: auto; margin-left: auto; margin-right: auto;
} }
@ -138,9 +138,123 @@
.dropdown-list { overflow-y: auto; max-height: 300px; } .dropdown-list { overflow-y: auto; max-height: 300px; }
.dropdown-item-custom { padding: 10px 16px; font-size: 0.85rem; color: var(--text); cursor: pointer; border-bottom: 1px solid rgba(0,0,0,0.03); transition: background 0.1s; &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } } .dropdown-item-custom { padding: 10px 16px; font-size: 0.85rem; color: var(--text); cursor: pointer; border-bottom: 1px solid rgba(0,0,0,0.03); transition: background 0.1s; &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } }
.additional-filter-wrap {
position: relative;
}
.btn-additional-filter {
min-width: 160px;
max-width: 230px;
}
.additional-dropdown {
width: min(420px, calc(100vw - 24px));
max-height: 460px;
padding: 10px;
overflow: auto;
gap: 10px;
}
.additional-dropdown-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.additional-dropdown-title {
font-size: 0.74rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.6);
}
.additional-dropdown-footer {
padding-top: 4px;
display: flex;
justify-content: flex-start;
}
.additional-mode-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.additional-mode-btn {
border: 1px solid rgba(17, 18, 20, 0.12);
background: rgba(255, 255, 255, 0.7);
color: var(--muted);
font-weight: 800;
font-size: 0.8rem;
border-radius: 999px;
padding: 6px 12px;
transition: all 0.2s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
background: #fff;
}
&.active {
border-color: var(--brand);
color: var(--brand);
background: #fff;
box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.additional-services-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.additional-chip-btn {
border: 1px solid rgba(17, 18, 20, 0.12);
background: rgba(255, 255, 255, 0.68);
color: var(--muted);
font-weight: 700;
font-size: 0.78rem;
border-radius: 999px;
padding: 5px 10px;
transition: all 0.2s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
background: #fff;
}
&.active {
border-color: var(--brand);
color: var(--brand);
background: rgba(227, 61, 207, 0.12);
}
&.clear {
color: var(--blue);
border-color: rgba(3, 15, 170, 0.18);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* KPIs */ /* KPIs */
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } } .geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
.kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(8px); transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.02); &:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1); background: #fff; border-color: var(--brand); } .lbl { font-size: 0.72rem; font-weight: 900; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); &.text-success { color: var(--success-text) !important; } &.text-danger { color: var(--danger-text) !important; } } .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } } .kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(8px); transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.02); &:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1); background: #fff; border-color: var(--brand); } .lbl { font-size: 0.72rem; font-weight: 900; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); &.text-success { color: var(--success-text) !important; } &.text-danger { color: var(--danger-text) !important; } } .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } }
.kpi .val-loading { font-size: 0.86rem; font-weight: 900; color: var(--muted); display: inline-flex; align-items: center; }
/* Insights */
/* Controls */ /* Controls */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
@ -166,6 +280,10 @@
.group-info { display: flex; flex-direction: column; gap: 6px; } .group-info { display: flex; flex-direction: column; gap: 6px; }
.group-badges { display: flex; gap: 8px; flex-wrap: wrap; } .group-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase; &.total { background: rgba(3,15,170,0.1); color: var(--blue); } &.ok { background: var(--success-bg); color: var(--success-text); } } .badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase; &.total { background: rgba(3,15,170,0.1); color: var(--blue); } &.ok { background: var(--success-bg); color: var(--success-text); } }
.group-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-pill { font-size: 0.65rem; padding: 4px 8px; border-radius: 999px; font-weight: 800; text-transform: uppercase; background: rgba(3,15,170,0.08); color: var(--blue); border: 1px solid rgba(3,15,170,0.16); }
.tag-pill.active { background: var(--success-bg); color: var(--success-text); border-color: rgba(25,135,84,0.22); }
.tag-pill.blocked { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(220,53,69,0.22); }
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; } .group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
.client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); } .client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
@ -209,6 +327,7 @@
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } .modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; } .modal-body .box-body { overflow: visible; }
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,259 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-danger border-0 shadow" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header border-bottom-0">
<strong class="me-auto text-primary">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark">
{{ toastMessage }}
</div>
</div>
</div>
<section class="historico-page">
<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="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-clock-history"></i> Auditoria
</div>
<div class="header-title">
<h5 class="title mb-0">Histórico</h5>
<small class="subtitle">Registros de alterações feitas no sistema.</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
</div>
<div class="filters-card mt-4">
<div class="filters-head">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros</span>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="applyFilters()" [disabled]="loading">
<i class="bi bi-check2"></i> Aplicar
</button>
<button class="btn-ghost" type="button" (click)="clearFilters()" [disabled]="loading">
<i class="bi bi-x-circle"></i> Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field filter-search">
<label>Período (De)</label>
<input type="date" [(ngModel)]="dateFrom" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Período (Até)</label>
<input type="date" [(ngModel)]="dateTo" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Página</label>
<app-select
class="select-glass"
size="sm"
[options]="pageOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterPageName"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Ação</label>
<app-select
class="select-glass"
size="sm"
[options]="actionOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterAction"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Usuário (ID)</label>
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Busca geral</label>
<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"></i>
</span>
<input
class="form-control"
placeholder="Pesquisar..."
[(ngModel)]="filterSearch"
(ngModelChange)="onSearchChange()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="filterSearch">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="geral-body">
<div class="table-wrap">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="alert alert-danger m-4" role="alert" *ngIf="!loading && error">
{{ errorMsg || 'Erro ao carregar histórico.' }}
<button class="btn btn-sm btn-outline-danger ms-3" type="button" (click)="refresh()">Tentar novamente</button>
</div>
<div class="empty-group" *ngIf="!loading && !error && logs.length === 0">
Nenhum log encontrado para os filtros atuais.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !error && logs.length > 0">
<thead>
<tr>
<th>Data/Hora</th>
<th>Usuário</th>
<th>Página</th>
<th>Ação</th>
<th>Item/Entidade</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let log of logs; trackBy: trackByLog">
<tr class="table-row-item" [class.expanded]="expandedLogId === log.id">
<td class="fw-bold text-muted">{{ formatDateTime(log.occurredAtUtc) }}</td>
<td>
<div class="user-cell">
<span class="user-name">{{ displayUserName(log) }}</span>
<small class="user-email">{{ log.userEmail || '-' }}</small>
</div>
</td>
<td class="td-clip" [title]="log.page">{{ log.page || '-' }}</td>
<td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td>
<td>
<div class="entity-cell">
<div class="entity-label td-clip" [title]="displayEntity(log)">
{{ displayEntity(log) }}
</div>
<button
class="expand-btn"
type="button"
(click)="toggleDetails(log, $event)"
[attr.aria-expanded]="expandedLogId === log.id"
[attr.aria-label]="expandedLogId === log.id ? 'Fechar detalhes' : 'Abrir detalhes'">
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
</button>
</div>
<small class="entity-id" *ngIf="log.entityId">{{ log.entityId }}</small>
</td>
</tr>
<tr class="details-row" *ngIf="expandedLogId === log.id">
<td colspan="5">
<div class="details-panel">
<div class="details-section">
<div class="section-title">
<i class="bi bi-pencil-square"></i> Mudanças
</div>
<div class="changes-list" *ngIf="log.changes?.length; else noChanges">
<div class="change-item" *ngFor="let change of log.changes; trackBy: trackByField">
<div class="change-head">
<span class="change-field">{{ change.field }}</span>
<span class="change-type" [ngClass]="changeTypeClass(change.changeType)">
{{ changeTypeLabel(change.changeType) }}
</span>
</div>
<div class="change-values">
<span class="old">{{ formatChangeValue(change.oldValue) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(change.newValue) }}</span>
</div>
</div>
</div>
<ng-template #noChanges>
<div class="empty-state">Sem mudanças registradas.</div>
</ng-template>
</div>
<div class="details-section tech">
<div class="section-title">
<i class="bi bi-terminal"></i> Detalhes técnicos
</div>
<div class="tech-grid">
<div class="tech-item">
<span class="tech-label">Método</span>
<span class="tech-value">{{ log.requestMethod || '-' }}</span>
</div>
<div class="tech-item">
<span class="tech-label">Endpoint</span>
<span class="tech-value">{{ log.requestPath || '-' }}</span>
</div>
<div class="tech-item" *ngIf="log.ipAddress">
<span class="tech-label">IP</span>
<span class="tech-value">{{ log.ipAddress }}</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div class="geral-footer">
<div class="footer-meta">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} registros</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>
<div class="select-wrapper">
<app-select
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="pageSize"
(ngModelChange)="onPageSizeChange()"
[disabled]="loading">
</app-select>
</div>
</div>
</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>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,679 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--success-bg: rgba(25, 135, 84, 0.12);
--success-text: #198754;
--info-bg: rgba(3, 15, 170, 0.1);
--info-text: #030FAA;
--danger-bg: rgba(220, 53, 69, 0.12);
--danger-text: #dc3545;
--radius-xl: 22px;
--radius-lg: 16px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
.historico-page {
min-height: 100vh;
padding: 0 12px;
display: flex;
align-items: flex-start;
justify-content: center;
position: relative;
overflow-y: auto;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 255, 255, 0.25);
}
}
.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; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
.container-geral-responsive {
width: 100%;
max-width: 1400px !important;
width: 98% !important;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 200px;
}
.geral-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
min-height: 80vh;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: calc(var(--radius-xl) - 1px);
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.65);
opacity: 0.75;
}
}
.geral-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
background: linear-gradient(180deg, rgba(227,61,207,0.06), rgba(255,255,255,0.2));
flex-shrink: 0;
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
text-align: center;
gap: 16px;
.title-badge { justify-self: center; margin-bottom: 8px; }
.header-actions { justify-self: center; }
}
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
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-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.title {
font-size: 26px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
margin-bottom: 0;
}
.subtitle {
color: rgba(17, 18, 20, 0.65);
font-weight: 700;
}
.header-actions {
justify-self: end;
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); filter: brightness(1.05); }
}
.filters-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 14px;
box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08);
}
.filters-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 900;
font-size: 14px;
color: rgba(17, 18, 20, 0.82);
}
.filters-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.filter-field {
display: grid;
gap: 6px;
label {
font-size: 11px;
font-weight: 800;
color: rgba(17, 18, 20, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input {
height: 40px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15);
padding: 0 12px;
font-size: 14px;
background: #fff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12);
}
}
.filter-search {
grid-column: span 2;
}
.search-group {
max-width: 270px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: stretch;
background: #fff;
border: 1px solid rgba(17, 18, 20, 0.15);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
&:focus-within {
border-color: var(--brand);
box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15);
transform: translateY(-1px);
}
.input-group-text {
background: transparent;
border: none;
color: rgba(17, 18, 20, 0.5);
padding-left: 14px;
padding-right: 8px;
display: flex;
align-items: center;
}
.form-control {
border: none;
background: transparent;
height: auto;
padding: 10px 0;
font-size: 0.9rem;
color: var(--text);
box-shadow: none;
&::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; }
&:focus { outline: none; }
}
.btn-clear {
border: none;
background: transparent;
color: rgba(17, 18, 20, 0.45);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 12px;
cursor: pointer;
transition: color 0.2s ease;
&:hover { color: #dc3545; }
}
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: 10px;
border: none;
font-weight: 700;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary {
background: var(--blue);
color: #fff;
box-shadow: 0 8px 18px rgba(3, 15, 170, 0.18);
}
.btn-ghost {
background: #fff;
color: rgba(17, 18, 20, 0.86);
border: 1px solid rgba(15, 23, 42, 0.12);
}
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(17, 18, 20, 0.12);
border-radius: 10px;
color: var(--blue);
font-weight: 800;
}
.geral-body {
padding: 0;
background: transparent;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.table-wrap {
overflow-x: auto;
overflow-y: auto;
height: 100%;
}
.table-modern {
width: 100%;
min-width: 1200px !important;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 2px solid rgba(227, 61, 207, 0.15);
padding: 12px;
color: rgba(17, 18, 20, 0.7);
font-size: 0.8rem;
font-weight: 950;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
text-align: center;
}
tbody tr {
transition: background-color 0.2s;
border-bottom: 1px solid rgba(17,18,20,0.05);
&:hover {
background-color: rgba(227, 61, 207, 0.05);
}
td { border-bottom: 1px solid rgba(17,18,20,0.04); }
}
td {
padding: 12px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
color: var(--text);
text-align: center;
}
}
.table-modern th:nth-child(5),
.table-modern td:nth-child(5) {
text-align: left;
}
.table-modern th:nth-child(2),
.table-modern td:nth-child(2) {
min-width: 260px;
}
.table-row-item.expanded {
background: rgba(227, 61, 207, 0.06);
}
.td-clip {
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.empty-group {
background: rgba(255,255,255,0.7);
border: 1px dashed rgba(17,18,20,0.12);
border-radius: 16px;
padding: 18px;
text-align: center;
font-weight: 800;
color: var(--muted);
margin: 16px;
}
.user-cell {
display: grid;
gap: 2px;
justify-items: center;
}
.user-name {
font-weight: 800;
color: var(--text);
}
.user-email {
color: rgba(17, 18, 20, 0.55);
font-size: 0.75rem;
}
.entity-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.entity-label {
font-weight: 700;
color: var(--text);
}
.entity-id {
display: block;
font-size: 0.75rem;
color: rgba(17, 18, 20, 0.5);
word-break: break-all;
}
.expand-btn {
border: none;
background: rgba(3, 15, 170, 0.08);
color: var(--blue);
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, background 0.15s ease;
&:hover { transform: translateY(-1px); background: rgba(227, 61, 207, 0.12); color: var(--brand); }
}
.badge-action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.action-create {
background: var(--success-bg);
color: var(--success-text);
}
.action-update {
background: var(--info-bg);
color: var(--info-text);
}
.action-delete {
background: var(--danger-bg);
color: var(--danger-text);
}
.action-default {
background: rgba(17,18,20,0.08);
color: rgba(17,18,20,0.7);
}
.details-row td {
background: rgba(255, 255, 255, 0.95);
padding: 0 12px 16px;
white-space: normal;
overflow: visible;
}
.details-panel {
border-radius: 16px;
border: 1px solid rgba(17, 18, 20, 0.08);
background: #fff;
padding: 16px;
display: grid;
gap: 16px;
}
.details-section {
display: grid;
gap: 12px;
}
.section-title {
font-weight: 900;
font-size: 0.9rem;
color: var(--text);
display: inline-flex;
align-items: center;
gap: 8px;
}
.changes-list {
display: grid;
gap: 10px;
}
.change-item {
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.9);
display: grid;
gap: 6px;
}
.change-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.change-field {
font-weight: 800;
color: var(--text);
}
.change-type {
font-size: 0.7rem;
font-weight: 900;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 999px;
letter-spacing: 0.04em;
}
.change-added { background: var(--success-bg); color: var(--success-text); }
.change-removed { background: var(--danger-bg); color: var(--danger-text); }
.change-modified { background: var(--info-bg); color: var(--info-text); }
.change-values {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 0.8rem;
color: rgba(17, 18, 20, 0.7);
}
.change-values .old {
color: rgba(17, 18, 20, 0.6);
}
.change-values .new {
color: var(--text);
font-weight: 700;
}
.empty-state {
background: rgba(17, 18, 20, 0.04);
border: 1px dashed rgba(17, 18, 20, 0.1);
border-radius: 12px;
padding: 12px;
font-weight: 700;
color: rgba(17, 18, 20, 0.6);
}
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.tech-item {
display: grid;
gap: 4px;
}
.tech-label {
font-size: 0.7rem;
font-weight: 800;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.55);
}
.tech-value {
font-weight: 700;
color: var(--text);
word-break: break-word;
}
.geral-footer {
padding: 12px 20px 18px;
border-top: 1px solid rgba(17,18,20,0.06);
background: rgba(255, 255, 255, 0.6);
display: grid;
gap: 12px;
}
.footer-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.pagination-modern .page-link {
color: var(--blue);
font-weight: 900;
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.1);
background: rgba(255,255,255,0.6);
margin: 0 2px;
&:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); }
}
.pagination-modern .page-item.active .page-link {
background-color: var(--blue);
border-color: var(--blue);
color: #fff;
}
.text-brand { color: var(--brand) !important; }
@media (max-width: 1100px) {
.filters-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.filters-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
.filters-grid { grid-template-columns: 1fr; }
.search-group { width: 100%; max-width: 100%; }
.entity-cell { flex-direction: column; align-items: flex-start; }
.expand-btn { align-self: flex-end; }
}

View File

@ -0,0 +1,275 @@
import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { HistoricoService, AuditLogDto, AuditChangeType, HistoricoQuery } from '../../services/historico.service';
interface SelectOption {
value: string;
label: string;
}
@Component({
selector: 'app-historico',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './historico.html',
styleUrls: ['./historico.scss'],
})
export class Historico implements OnInit {
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
logs: AuditLogDto[] = [];
loading = false;
error = false;
errorMsg = '';
toastMessage = '';
expandedLogId: string | null = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
filterPageName = '';
filterAction = '';
filterUserId = '';
filterSearch = '';
dateFrom = '';
dateTo = '';
readonly pageOptions: SelectOption[] = [
{ value: '', label: 'Todas as páginas' },
{ value: 'Geral', label: 'Geral' },
{ value: 'Mureg', label: 'Mureg' },
{ value: 'Faturamento', label: 'Faturamento' },
{ value: 'Parcelamentos', label: 'Parcelamentos' },
{ value: 'Dados e Usuários', label: 'Dados PF/PJ' },
{ value: 'Vigência', label: 'Vigência' },
{ value: 'Chips Virgens e Recebidos', label: 'Chips Virgens e Recebidos' },
{ value: 'Troca de número', label: 'Troca de número' },
];
readonly actionOptions: SelectOption[] = [
{ value: '', label: 'Todas as ações' },
{ value: 'CREATE', label: 'Criação' },
{ value: 'UPDATE', label: 'Atualização' },
{ value: 'DELETE', label: 'Exclusão' },
];
private searchTimer: any = null;
constructor(
private historicoService: HistoricoService,
private cdr: ChangeDetectorRef,
@Inject(PLATFORM_ID) private platformId: object
) {}
ngOnInit(): void {
this.fetch(1);
}
refresh(): void {
this.fetch(1);
}
applyFilters(): void {
this.page = 1;
this.fetch();
}
clearFilters(): void {
this.filterPageName = '';
this.filterAction = '';
this.filterUserId = '';
this.filterSearch = '';
this.dateFrom = '';
this.dateTo = '';
this.page = 1;
this.fetch();
}
clearSearch(): void {
this.filterSearch = '';
this.page = 1;
this.fetch();
}
onSearchChange(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.fetch();
}, 300);
}
onPageSizeChange(): void {
this.page = 1;
this.fetch();
}
goToPage(p: number): void {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.fetch();
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
}
get pageNumbers(): number[] {
const total = this.totalPages;
const current = this.page;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
toggleDetails(log: AuditLogDto, event?: Event): void {
if (event) event.stopPropagation();
this.expandedLogId = this.expandedLogId === log.id ? null : log.id;
}
formatDateTime(value?: string | null): string {
if (!value) return '-';
const d = new Date(value);
if (isNaN(d.getTime())) return '-';
return d.toLocaleString('pt-BR');
}
displayUserName(log: AuditLogDto): string {
const name = (log.userName || '').trim();
return name ? name : 'SISTEMA';
}
displayEntity(log: AuditLogDto): string {
const label = (log.entityLabel || '').trim();
if (label) return label;
return log.entityName || '-';
}
formatAction(action?: string | null): string {
const value = (action || '').toUpperCase();
if (!value) return '-';
if (value === 'CREATE') return 'Criação';
if (value === 'UPDATE') return 'Atualização';
if (value === 'DELETE') return 'Exclusão';
return 'Outro';
}
actionClass(action?: string | null): string {
const value = (action || '').toUpperCase();
if (value === 'CREATE') return 'action-create';
if (value === 'UPDATE') return 'action-update';
if (value === 'DELETE') return 'action-delete';
return 'action-default';
}
changeTypeLabel(type?: AuditChangeType | string | null): string {
if (!type) return 'Alterado';
if (type === 'added') return 'Adicionado';
if (type === 'removed') return 'Removido';
return 'Alterado';
}
changeTypeClass(type?: AuditChangeType | string | null): string {
if (type === 'added') return 'change-added';
if (type === 'removed') return 'change-removed';
if (type === 'modified') return 'change-modified';
return 'change-modified';
}
formatChangeValue(value?: string | null): string {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
trackByLog(_: number, log: AuditLogDto): string {
return log.id;
}
trackByField(_: number, change: { field: string }): string {
return change.field;
}
private fetch(goToPage?: number): void {
if (goToPage) this.page = goToPage;
this.loading = true;
this.error = false;
this.errorMsg = '';
this.expandedLogId = null;
const query: HistoricoQuery = {
page: this.page,
pageSize: this.pageSize,
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
userId: this.filterUserId?.trim() || undefined,
search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
this.historicoService.list(query).subscribe({
next: (res) => {
this.logs = res.items || [];
this.total = res.total || 0;
this.page = res.page || this.page;
this.pageSize = res.pageSize || this.pageSize;
this.loading = false;
},
error: (err: HttpErrorResponse) => {
this.error = true;
if (err?.status === 403) {
this.errorMsg = 'Acesso restrito.';
} else {
this.errorMsg = 'Erro ao carregar histórico. Tente novamente.';
}
this.loading = false;
},
});
}
private toIsoDate(value: string, endOfDay: boolean): string | null {
if (!value) return null;
const time = endOfDay ? '23:59:59' : '00:00:00';
const date = new Date(`${value}T${time}`);
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
private async showToast(message: string) {
if (!isPlatformBrowser(this.platformId)) return;
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
});
toastInstance.show();
} catch (error) {
console.error(error);
}
}
}

View File

@ -50,7 +50,6 @@
<span class="checkmark"></span> <span class="checkmark"></span>
Lembrar de mim Lembrar de mim
</label> </label>
<a href="#" class="forgot-link">Esqueceu a senha?</a>
</div> </div>
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid"> <button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">

View File

@ -69,18 +69,6 @@ export class LoginComponent {
} }
} }
private saveToken(token: string) {
// ✅ SSR-safe
if (!isPlatformBrowser(this.platformId)) return;
// 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 { onSubmit(): void {
console.log('🚀 Iniciando login...'); console.log('🚀 Iniciando login...');
this.apiError = ''; this.apiError = '';
@ -94,7 +82,10 @@ export class LoginComponent {
this.isSubmitting = true; this.isSubmitting = true;
const v = this.loginForm.value; const v = this.loginForm.value;
this.authService.login({ email: v.username, password: v.password }).subscribe({ this.authService.login(
{ email: v.username, password: v.password },
{ rememberMe: !!v.rememberMe }
).subscribe({
next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
console.log('✅ Resposta da API:', res); console.log('✅ Resposta da API:', res);
this.isSubmitting = false; this.isSubmitting = false;
@ -109,8 +100,7 @@ export class LoginComponent {
return; return;
} }
console.log('🔑 Token encontrado. Salvando...'); this.authService.setToken(token, !!v.rememberMe);
this.saveToken(token);
const payload = this.authService.getTokenPayload(); const payload = this.authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId']; const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];

View File

@ -24,6 +24,39 @@
Arquivadas / Lidas Arquivadas / Lidas
</button> </button>
</div> </div>
<div class="bulk-actions-bar" *ngIf="!loading && !error">
<div class="bulk-left">
<label class="select-all" *ngIf="filter !== 'lidas' && filteredNotifications.length > 0">
<input type="checkbox" [checked]="isAllSelected" (change)="toggleSelectAll()" />
<span>Selecionar todas</span>
</label>
<span class="bulk-count">
Mostrando {{ filteredNotifications.length }} notificações
<span class="bulk-selected" *ngIf="selectedIds.size > 0">• {{ selectedIds.size }} selecionada(s)</span>
</span>
</div>
<div class="bulk-actions" *ngIf="filter !== 'lidas'">
<button
type="button"
class="bulk-btn"
(click)="markAllAsRead()"
[disabled]="bulkLoading || filteredNotifications.length === 0"
>
<span *ngIf="!bulkLoading"><i class="bi bi-check2-all me-1"></i> Ler todas</span>
<span *ngIf="bulkLoading"><span class="spinner-border spinner-border-sm me-2"></span> Marcando...</span>
</button>
<button
type="button"
class="bulk-btn ghost"
(click)="exportNotifications()"
[disabled]="exportLoading || filteredNotifications.length === 0"
>
<span *ngIf="!exportLoading"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exportLoading"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
</div>
</div> </div>
<div class="state-container" *ngIf="loading"> <div class="state-container" *ngIf="loading">
@ -46,11 +79,6 @@
</div> </div>
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0"> <div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
<div class="list-header-actions">
<span>Mostrando {{ filteredNotifications.length }} notificações</span>
</div>
<div <div
class="list-item" class="list-item"
*ngFor="let n of filteredNotifications" *ngFor="let n of filteredNotifications"
@ -60,6 +88,11 @@
> >
<div class="status-strip"></div> <div class="status-strip"></div>
<label class="item-select" *ngIf="filter !== 'lidas'">
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
<span></span>
</label>
<div class="item-icon"> <div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="n.tipo === 'Vencido'" [class.bi-clock-fill]="n.tipo === 'AVencer'"></i> <i class="bi" [class.bi-x-circle-fill]="n.tipo === 'Vencido'" [class.bi-clock-fill]="n.tipo === 'AVencer'"></i>
</div> </div>
@ -68,25 +101,34 @@
<div class="content-top"> <div class="content-top">
<h4 class="item-title"> <h4 class="item-title">
{{ n.linha || 'Linha Desconhecida' }} {{ n.linha || 'Linha Desconhecida' }}
<span class="separator"></span>
<span class="item-client">{{ n.cliente || '-' }}</span>
</h4>
<div class="date-stack">
<span class="date-pill green">Efetivação: {{ formatDateLabel(n.dtEfetivacaoServico) }}</span>
<span class="date-pill red">Término: {{ formatDateLabel(n.dtTerminoFidelizacao) }}</span>
</div>
</div>
<div class="item-meta-grid">
<div class="meta-row">
<span class="meta-label">Conta</span>
<span class="meta-value">{{ n.conta || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Usuário</span>
<span class="meta-value">{{ n.usuario || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Plano</span>
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
</div>
<div class="meta-row">
<span class="badge-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'"> <span class="badge-tag" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }} {{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
</span> </span>
</h4>
<span class="item-time">
{{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
</span>
</div> </div>
</div>
<p class="item-details">
<strong>Cliente:</strong> {{ n.cliente || '-' }} • <strong>Usuário:</strong> {{ n.usuario || '-' }}
</p>
<p class="item-message" *ngIf="n.tipo === 'Vencido'">
A vigência desta linha expirou. Verifique a renovação imediatamente.
</p>
<p class="item-message" *ngIf="n.tipo === 'AVencer'">
A vigência irá expirar em breve. Programe-se.
</p>
</div> </div>
<div class="item-actions"> <div class="item-actions">

View File

@ -30,6 +30,74 @@ $border: #e5e7eb;
p { color: $text-secondary; font-size: 16px; margin-bottom: 24px; } p { color: $text-secondary; font-size: 16px; margin-bottom: 24px; }
} }
.bulk-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.bulk-actions-bar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.bulk-count {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bulk-selected {
color: $text-main;
}
.select-all {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
input {
width: 20px;
height: 20px;
accent-color: $primary;
}
}
.bulk-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.bulk-btn {
background: $white;
border: 1px solid $border;
padding: 8px 12px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
color: $text-main;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
cursor: pointer;
&:hover { border-color: $primary; color: $primary; }
&:disabled { opacity: 0.6; cursor: default; }
&.ghost { background: transparent; }
}
/* FILTROS (Estilo Tabs/Pills) */ /* FILTROS (Estilo Tabs/Pills) */
.filters-bar { .filters-bar {
display: inline-flex; display: inline-flex;
@ -93,10 +161,7 @@ $border: #e5e7eb;
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 12px;
} }
.list-header-actions { /* list-header-actions removido */
font-size: 12px; font-weight: 600; color: $text-secondary; text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 8px; padding-left: 8px;
}
.list-item { .list-item {
background: $white; background: $white;
@ -125,6 +190,24 @@ $border: #e5e7eb;
} }
} }
.item-select {
margin-left: 8px;
margin-right: 8px;
min-width: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
align-self: center;
input {
width: 20px;
height: 20px;
accent-color: $primary;
}
}
.status-strip { .status-strip {
position: absolute; left: 0; top: 0; bottom: 0; width: 4px; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
} }
@ -141,33 +224,66 @@ $border: #e5e7eb;
.item-content { flex: 1; min-width: 0; } .item-content { flex: 1; min-width: 0; }
.content-top { .content-top {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; display: grid;
margin-bottom: 6px; grid-template-columns: 1fr auto;
align-items: start;
gap: 10px;
margin-bottom: 10px;
} }
.item-title { .item-title {
font-size: 16px; font-weight: 700; color: $text-main; margin: 0; font-size: 16px;
display: flex; align-items: center; gap: 8px; font-weight: 800;
color: $text-main;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
} }
.separator { color: $text-secondary; }
.item-client { font-weight: 600; color: $text-secondary; }
.date-stack {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
min-width: 170px;
text-align: right;
}
.date-pill {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.4px;
&.green { background: rgba($success, 0.12); color: $success; }
&.red { background: rgba($danger, 0.12); color: $danger; }
}
.item-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.meta-row { display: flex; flex-direction: column; gap: 2px; }
.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: $text-secondary; font-weight: 700; }
.meta-value { font-size: 13px; font-weight: 600; color: $text-main; }
.badge-tag { .badge-tag {
font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; font-size: 10px; text-transform: uppercase; padding: 4px 8px; border-radius: 999px;
font-weight: 800; letter-spacing: 0.5px; font-weight: 800; letter-spacing: 0.5px;
width: fit-content;
&.danger { background: rgba($danger, 0.1); color: $danger; } &.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); } &.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
} }
.item-time { font-size: 12px; color: $text-secondary; font-weight: 500; }
.item-details {
font-size: 13px; color: $text-secondary; margin: 0 0 4px;
}
.item-message {
font-size: 13px; color: $text-secondary; margin: 0; opacity: 0.8;
}
.item-actions { .item-actions {
margin-left: 12px; align-self: center; margin-left: 12px; align-self: center;
} }

View File

@ -15,6 +15,9 @@ export class Notificacoes implements OnInit {
filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas'; filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas';
loading = false; loading = false;
error = false; error = false;
bulkLoading = false;
exportLoading = false;
selectedIds = new Set<string>();
constructor(private notificationsService: NotificationsService) {} constructor(private notificationsService: NotificationsService) {}
@ -34,6 +37,7 @@ export class Notificacoes implements OnInit {
setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') { setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') {
this.filter = value; this.filter = value;
this.clearSelection();
} }
get filteredNotifications() { get filteredNotifications() {
@ -49,6 +53,11 @@ export class Notificacoes implements OnInit {
return this.notifications; return this.notifications;
} }
formatDateLabel(date?: string | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('pt-BR');
}
private loadNotifications() { private loadNotifications() {
this.loading = true; this.loading = true;
this.error = false; this.error = false;
@ -66,5 +75,118 @@ export class Notificacoes implements OnInit {
countByType(tipo: 'Vencido' | 'AVencer'): number { countByType(tipo: 'Vencido' | 'AVencer'): number {
return this.notifications.filter(n => n.tipo === tipo && !n.lida).length; return this.notifications.filter(n => n.tipo === tipo && !n.lida).length;
} }
markAllAsRead() {
if (this.filter === 'lidas' || this.bulkLoading) return;
this.bulkLoading = true;
const filterParam = this.getFilterParam();
const ids = Array.from(this.selectedIds);
this.notificationsService.markAllAsRead(filterParam, ids.length ? ids : undefined).subscribe({
next: () => {
const now = new Date().toISOString();
this.notifications = this.notifications.map((n) => {
if (ids.length ? ids.includes(n.id) : this.shouldMarkRead(n)) {
return { ...n, lida: true, lidaEm: now };
}
return n;
});
this.clearSelection();
this.bulkLoading = false;
},
error: () => {
this.bulkLoading = false;
}
});
}
exportNotifications() {
if (this.filter === 'lidas' || this.exportLoading) return;
this.exportLoading = true;
const filterParam = this.getFilterParam();
const ids = Array.from(this.selectedIds);
this.notificationsService.export(filterParam, ids.length ? ids : undefined).subscribe({
next: (res) => {
const blob = res.body;
if (!blob) {
this.exportLoading = false;
return;
}
const filename = this.extractFilename(res.headers.get('content-disposition')) || this.buildDefaultFilename();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.clearSelection();
this.exportLoading = false;
},
error: () => {
this.exportLoading = false;
}
});
}
isSelected(notification: NotificationDto): boolean {
return this.selectedIds.has(notification.id);
}
toggleSelection(notification: NotificationDto) {
if (this.selectedIds.has(notification.id)) {
this.selectedIds.delete(notification.id);
} else {
this.selectedIds.add(notification.id);
}
}
get isAllSelected(): boolean {
const list = this.filteredNotifications;
return list.length > 0 && list.every(n => this.selectedIds.has(n.id));
}
toggleSelectAll() {
const list = this.filteredNotifications;
if (this.isAllSelected) {
this.clearSelection();
return;
}
list.forEach(n => this.selectedIds.add(n.id));
}
clearSelection() {
this.selectedIds.clear();
}
private getFilterParam(): string | undefined {
if (this.filter === 'aVencer') return 'a-vencer';
if (this.filter === 'vencidas') return 'vencidas';
if (this.filter === 'todas') return undefined;
return undefined;
}
private shouldMarkRead(n: NotificationDto): boolean {
if (this.filter === 'todas') return true;
if (this.filter === 'aVencer') return n.tipo === 'AVencer';
if (this.filter === 'vencidas') return n.tipo === 'Vencido';
return false;
}
private extractFilename(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const normalMatch = contentDisposition.match(/filename=\"?([^\";]+)\"?/i);
return normalMatch?.[1] ?? null;
}
private buildDefaultFilename(): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '').slice(0, 14);
return `notificacoes-${stamp}.xlsx`;
}
} }

View File

@ -63,7 +63,7 @@
<p>Gerencie permissões e status.</p> <p>Gerencie permissões e status.</p>
</div> </div>
<div class="list-actions"> <div class="list-actions">
<input type="text" placeholder="Buscar por nome ou email" [(ngModel)]="search" (keyup.enter)="onSearch()" /> <input type="text" placeholder="Pesquisar..." [(ngModel)]="search" (keyup.enter)="onSearch()" />
<button type="button" class="btn-secondary" (click)="onSearch()">Buscar</button> <button type="button" class="btn-secondary" (click)="onSearch()">Buscar</button>
<button type="button" class="btn-ghost" (click)="clearSearch()">Limpar</button> <button type="button" class="btn-ghost" (click)="clearSearch()">Limpar</button>
</div> </div>

View File

@ -0,0 +1,151 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-plus-circle"></i></span>
<span>{{ title }}</span>
</div>
<button class="btn-icon" type="button" (click)="close.emit()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<section class="form-section">
<div class="section-head">
<h4>Dados do parcelamento</h4>
<small>Preencha os campos obrigatorios para salvar o cadastro.</small>
</div>
<div class="form-grid">
<div class="form-field">
<label>Ano Ref *</label>
<input type="number" [(ngModel)]="model.anoRef" />
<small *ngIf="touched && !model.anoRef" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Item *</label>
<input type="number" [(ngModel)]="model.item" />
<small *ngIf="touched && !model.item" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Linha *</label>
<input type="text" [(ngModel)]="model.linha" />
<small *ngIf="touched && !model.linha" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Cliente *</label>
<input type="text" [(ngModel)]="model.cliente" />
<small *ngIf="touched && !model.cliente" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Qt Parcelas</label>
<input type="text" placeholder="1/12" [(ngModel)]="model.qtParcelas" (ngModelChange)="onQtParcelasChange()" />
</div>
<div class="form-field">
<label>Parcela atual</label>
<input type="number" min="0" [(ngModel)]="model.parcelaAtual" (ngModelChange)="onParcelaChange()" />
</div>
<div class="form-field">
<label>Total de parcelas *</label>
<input type="number" min="1" [(ngModel)]="model.totalParcelas" (ngModelChange)="onParcelaChange(); onValueChange()" />
<small *ngIf="touched && (!model.totalParcelas || model.totalParcelas <= 0)" class="error">Informe a quantidade.</small>
</div>
<div class="form-field">
<label>Valor cheio *</label>
<input type="text" placeholder="0,00" [(ngModel)]="model.valorCheio" (ngModelChange)="onValueChange()" />
<small *ngIf="touched && !model.valorCheio" class="error">Informe o valor.</small>
</div>
<div class="form-field">
<label>Desconto</label>
<input type="text" placeholder="0,00" [(ngModel)]="model.desconto" (ngModelChange)="onValueChange()" />
</div>
<div class="form-field">
<label>Valor com desconto</label>
<input type="text" [(ngModel)]="model.valorComDesconto" (ngModelChange)="onValorComDescontoChange()" />
</div>
<div class="form-field">
<label>Competencia inicial *</label>
<div class="competencia-row">
<input type="number" placeholder="Ano" [(ngModel)]="model.competenciaAno" (ngModelChange)="onCompetenciaChange()" />
<app-select
class="select-glass"
size="sm"
[options]="monthOptions"
labelKey="label"
valueKey="value"
placeholder="Mes"
[(ngModel)]="model.competenciaMes"
(ngModelChange)="onCompetenciaChange()">
</app-select>
</div>
<small *ngIf="touched && (!model.competenciaAno || !model.competenciaMes)" class="error">Informe ano e mes.</small>
</div>
<div class="form-field final">
<label>Competencia final</label>
<div class="final-box">{{ competenciaFinalLabel }}</div>
</div>
</div>
</section>
<section class="preview-card">
<div class="preview-head">
<div>
<h4>Parcelas por competencia</h4>
<small>Edite os valores por mes (max 36 exibidas)</small>
</div>
</div>
<div class="preview-table">
<table>
<thead>
<tr>
<th>Mes/Ano</th>
<th>Parcela</th>
<th class="text-end">Valor</th>
</tr>
</thead>
<tbody>
<tr *ngIf="previewRows.length === 0">
<td colspan="3" class="empty">Preencha as informacoes para gerar a previa.</td>
</tr>
<tr *ngFor="let row of previewRows; trackBy: trackByPreview">
<td>{{ row.label }}</td>
<td>{{ row.parcela }}</td>
<td class="text-end">
<input
type="text"
class="inline-input"
[ngModel]="row.valor"
(ngModelChange)="onPreviewValueChange(row.competencia, $event)"
placeholder="0,00" />
</td>
</tr>
</tbody>
</table>
</div>
<p class="todo-note" *ngIf="errorMessage">{{ errorMessage }}</p>
</section>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="close.emit()">Cancelar</button>
<button class="btn-primary" type="button" [disabled]="loading" (click)="onSave()">
{{ loading ? 'Salvando...' : submitLabel }}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,389 @@
:host {
display: block;
--brand: var(--pg-primary, #1f4fd6);
--blue: var(--pg-primary-strong, #153caa);
--focus-ring: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.15), rgba(15, 23, 42, 0.66) 42%),
rgba(15, 23, 42, 0.58);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1040px, 96vw);
max-height: 92vh;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 18px;
border: 1px solid var(--pg-border, #dbe3ef);
background: #fff;
box-shadow: var(--pg-shadow-lg, 0 24px 56px rgba(15, 23, 42, 0.25));
animation: pop-up 0.24s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--pg-border, #dbe3ef);
background: linear-gradient(180deg, #f4f8ff, #ffffff 85%);
}
.modal-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 0.98rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
.icon-bg {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(31, 79, 214, 0.12);
color: var(--blue);
}
}
.btn-icon {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text-soft, #64748b);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--brand);
color: var(--blue);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 18px;
background: linear-gradient(180deg, #f8fbff, #ffffff 82%);
display: grid;
gap: 16px;
}
.form-section {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 14px;
padding: 12px;
background: #fff;
display: grid;
gap: 12px;
}
.section-head {
display: grid;
gap: 3px;
h4 {
margin: 0;
font-size: 0.9rem;
color: var(--pg-text, #0f172a);
font-weight: 800;
}
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
label {
color: var(--pg-text-soft, #64748b);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
}
input {
height: 40px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
padding: 0 12px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
.error {
color: var(--pg-danger, #c52929);
font-size: 11px;
font-weight: 700;
}
}
.competencia-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: center;
}
.select-glass {
background: #fff;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: 10px;
color: var(--pg-text, #0f172a);
font-weight: 700;
}
.final-box {
min-height: 40px;
border-radius: 10px;
border: 1px dashed var(--pg-border-strong, #c8d4e4);
background: #f8fbff;
color: var(--pg-text-muted, #475569);
padding: 0 12px;
display: flex;
align-items: center;
font-weight: 700;
}
.preview-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 14px;
padding: 12px;
display: grid;
gap: 10px;
background: #fff;
}
.preview-head h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
.preview-head small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
.preview-table {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 10px;
max-height: 240px;
overflow: auto;
}
.preview-table table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 460px;
}
.preview-table th,
.preview-table td {
padding: 8px 10px;
border-bottom: 1px solid #e9eef6;
font-size: 12px;
color: var(--pg-text, #0f172a);
}
.preview-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f5f9ff;
color: var(--pg-text-soft, #64748b);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 10px;
font-weight: 800;
}
.preview-table .empty {
text-align: center;
color: var(--pg-text-muted, #475569);
font-weight: 700;
}
.inline-input {
width: 100%;
height: 32px;
border-radius: 8px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
text-align: right;
padding: 0 8px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
.todo-note {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(197, 41, 41, 0.28);
background: rgba(197, 41, 41, 0.1);
color: var(--pg-danger, #c52929);
font-size: 12px;
font-weight: 700;
}
.modal-footer {
border-top: 1px solid var(--pg-border, #dbe3ef);
padding: 12px 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #fff;
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: 10px;
border: 1px solid transparent;
padding: 0 14px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
}
}
.btn-primary {
color: #fff;
border-color: var(--blue);
background: linear-gradient(140deg, var(--brand), var(--blue));
box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28);
}
.btn-ghost {
color: var(--pg-text, #0f172a);
background: #fff;
border-color: var(--pg-border-strong, #c8d4e4);
}
.btn-primary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--brand);
color: var(--blue);
}
@keyframes pop-up {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 940px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.modal-header,
.modal-body,
.modal-footer {
padding-left: 12px;
padding-right: 12px;
}
.form-grid {
grid-template-columns: 1fr;
}
.modal-footer {
justify-content: stretch;
}
.modal-footer .btn-primary,
.modal-footer .btn-ghost {
flex: 1;
}
}

View File

@ -0,0 +1,253 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
export type MonthOption = { value: number; label: string };
export type ParcelamentoCreateModel = {
anoRef: number | null;
linha: string;
cliente: string;
item: number | null;
qtParcelas: string;
parcelaAtual: number | null;
totalParcelas: number | null;
valorCheio: string;
desconto: string;
valorComDesconto: string;
competenciaAno: number | null;
competenciaMes: number | null;
monthValues: Array<{ competencia: string; valor: string }>;
};
type PreviewRow = {
competencia: string;
label: string;
parcela: number;
valor: string;
};
@Component({
selector: 'app-parcelamento-create-modal',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './parcelamento-create-modal.html',
styleUrls: ['./parcelamento-create-modal.scss'],
})
export class ParcelamentoCreateModalComponent implements OnChanges {
@Input() open = false;
@Input() monthOptions: MonthOption[] = [];
@Input() model!: ParcelamentoCreateModel;
@Input() title = 'Novo Parcelamento';
@Input() submitLabel = 'Salvar';
@Input() loading = false;
@Input() errorMessage = '';
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<ParcelamentoCreateModel>();
touched = false;
previewRows: PreviewRow[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (changes['model'] && this.model) {
this.syncMonthValues();
return;
}
if (changes['open'] && this.model) {
this.rebuildPreviewRows();
}
}
onValueChange(): void {
const cheio = this.toNumber(this.model.valorCheio);
const desconto = this.toNumber(this.model.desconto);
if (cheio === null) {
this.model.valorComDesconto = '';
this.syncMonthValues();
return;
}
const calc = Math.max(0, cheio - (desconto ?? 0));
this.model.valorComDesconto = this.formatInput(calc);
this.syncMonthValues();
}
onCompetenciaChange(): void {
this.syncMonthValues();
}
onValorComDescontoChange(): void {
this.syncMonthValues();
}
onParcelaChange(): void {
this.syncQtParcelas();
this.syncMonthValues();
}
onQtParcelasChange(): void {
const parsed = this.parseQtParcelas(this.model.qtParcelas);
if (parsed) {
this.model.parcelaAtual = parsed.atual;
this.model.totalParcelas = parsed.total;
}
this.syncMonthValues();
}
get competenciaFinalLabel(): string {
if (this.model.monthValues?.length) {
const last = this.model.monthValues[this.model.monthValues.length - 1];
return this.formatCompetenciaLabel(last.competencia);
}
const total = this.model.totalParcelas ?? 0;
const ano = this.model.competenciaAno ?? 0;
const mes = this.model.competenciaMes ?? 0;
if (!total || !ano || !mes) return '-';
const index = (mes - 1) + (total - 1);
const finalAno = ano + Math.floor(index / 12);
const finalMes = (index % 12) + 1;
return `${String(finalMes).padStart(2, '0')}/${finalAno}`;
}
onPreviewValueChange(competencia: string, value: string): void {
const list = this.model.monthValues ?? [];
const item = list.find((entry) => entry.competencia === competencia);
if (item) item.valor = value ?? '';
const row = this.previewRows.find((entry) => entry.competencia === competencia);
if (row) row.valor = value ?? '';
}
trackByPreview(_: number, row: PreviewRow): string {
return row.competencia;
}
get isValid(): boolean {
return !!(
this.model.anoRef &&
this.model.item &&
this.model.linha?.trim() &&
this.model.cliente?.trim() &&
this.model.totalParcelas &&
this.model.totalParcelas > 0 &&
this.model.valorCheio &&
this.model.competenciaAno &&
this.model.competenciaMes
);
}
onSave(): void {
this.touched = true;
if (!this.isValid) return;
this.save.emit(this.model);
}
private syncQtParcelas(): void {
const atual = this.model.parcelaAtual;
const total = this.model.totalParcelas;
if (atual && total) {
this.model.qtParcelas = `${atual}/${total}`;
}
}
private syncMonthValues(): void {
const total = this.model.totalParcelas ?? 0;
const ano = this.model.competenciaAno ?? 0;
const mes = this.model.competenciaMes ?? 0;
if (!total || !ano || !mes) {
this.model.monthValues = [];
this.previewRows = [];
return;
}
const existing = new Map<string, string>();
(this.model.monthValues ?? []).forEach((m) => {
if (m?.competencia) existing.set(m.competencia, m.valor ?? '');
});
const valorTotal = this.toNumber(this.model.valorComDesconto) ?? this.toNumber(this.model.valorCheio);
const valorParcela = valorTotal !== null ? valorTotal / total : null;
const defaultValor = valorParcela !== null ? this.formatInput(valorParcela) : '';
const list: Array<{ competencia: string; valor: string }> = [];
for (let i = 0; i < total; i++) {
const index = (mes - 1) + i;
const y = ano + Math.floor(index / 12);
const m = (index % 12) + 1;
const competencia = `${y}-${String(m).padStart(2, '0')}-01`;
list.push({
competencia,
valor: existing.get(competencia) ?? defaultValor,
});
}
this.model.monthValues = list;
this.rebuildPreviewRows();
}
private rebuildPreviewRows(): void {
const list = this.model?.monthValues ?? [];
if (!list.length) {
this.previewRows = [];
return;
}
this.previewRows = list.slice(0, 36).map((item, idx) => ({
competencia: item.competencia,
label: this.formatCompetenciaLabel(item.competencia),
parcela: idx + 1,
valor: item.valor ?? '',
}));
}
private formatCompetenciaLabel(value: string): string {
const match = value.match(/^(\d{4})-(\d{2})/);
if (!match) return value || '-';
return `${match[2]}/${match[1]}`;
}
private parseQtParcelas(raw: string | null | undefined): { atual: number; total: number } | null {
if (!raw) return null;
const parts = raw.split('/');
if (parts.length < 2) return null;
const atualStr = this.onlyDigits(parts[0]);
const totalStr = this.onlyDigits(parts[1]);
if (!atualStr || !totalStr) return null;
return { atual: Number(atualStr), total: Number(totalStr) };
}
private toNumber(value: any): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const raw = String(value).trim();
if (!raw) return null;
let cleaned = raw.replace(/[^\d,.-]/g, '');
if (cleaned.includes(',') && cleaned.includes('.')) {
if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) {
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
cleaned = cleaned.replace(/,/g, '');
}
} else if (cleaned.includes(',')) {
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
cleaned = cleaned.replace(/,/g, '');
}
const n = Number(cleaned);
return Number.isNaN(n) ? null : n;
}
private onlyDigits(value: string): string {
let out = '';
for (const ch of value ?? '') {
if (ch >= '0' && ch <= '9') out += ch;
}
return out;
}
private formatInput(value: number): string {
return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
}
}

View File

@ -0,0 +1,56 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card annual-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-table"></i></span>
<span>Detalhamento Completo - {{ selectedYear }}</span>
</div>
<div class="modal-actions">
<select [ngModel]="selectedYear" (ngModelChange)="onYearChange($event)">
<option *ngFor="let y of years" [value]="y">{{ y }}</option>
</select>
<button class="btn-icon" type="button" (click)="close.emit()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="annual-table" *ngIf="data; else emptyState">
<table>
<thead>
<tr>
<th class="sticky-col col-1">Cliente</th>
<th class="sticky-col col-2">Linha</th>
<th class="sticky-col col-3">Item</th>
<th class="sticky-col col-4 text-end">Total</th>
<th class="sticky-col col-5">Parc.</th>
<th *ngFor="let m of data.months" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky-col col-1">{{ data.cliente || '-' }}</td>
<td class="sticky-col col-2">{{ data.linha || '-' }}</td>
<td class="sticky-col col-3">{{ data.item || '-' }}</td>
<td class="sticky-col col-4 text-end">{{ data.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td class="sticky-col col-5">{{ data.parcelasLabel }}</td>
<td *ngFor="let m of data.months" class="text-end">
{{ m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #emptyState>
<div class="empty-state">Sem dados para o ano selecionado.</div>
</ng-template>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="close.emit()">Fechar</button>
</div>
</div>
</div>

View File

@ -0,0 +1,202 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16);
}
.lg-backdrop {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.2), rgba(0, 0, 0, 0.56) 42%);
z-index: 9990;
backdrop-filter: blur(5px);
}
.lg-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 20px;
box-shadow: 0 30px 62px -16px rgba(0, 0, 0, 0.42);
width: min(1200px, 98vw);
overflow: hidden;
animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.1), rgba(255, 255, 255, 0.95) 72%);
}
.modal-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 900;
.icon-bg {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(3, 15, 170, 0.1);
color: var(--blue);
}
}
.modal-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
select {
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
padding: 6px 10px;
font-weight: 800;
background: rgba(255, 255, 255, 0.92);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.86);
color: rgba(17, 18, 20, 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: rgba(227, 61, 207, 0.26);
background: #fff;
color: var(--brand);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.modal-body {
padding: 16px;
background: linear-gradient(180deg, rgba(248, 249, 251, 0.98), rgba(255, 255, 255, 0.98));
}
.annual-table {
overflow-x: auto;
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
background: #fff;
}
.annual-table table {
border-collapse: collapse;
min-width: 1100px;
width: 100%;
font-size: 12px;
}
.annual-table th,
.annual-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
white-space: nowrap;
}
.annual-table thead th {
background: #f8f9fb;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 10px;
color: rgba(17, 18, 20, 0.6);
}
.sticky-col {
position: sticky;
left: 0;
background: #fff;
z-index: 2;
box-shadow: 2px 0 0 rgba(17, 18, 20, 0.04);
}
.col-1 { left: 0; min-width: 180px; }
.col-2 { left: 180px; min-width: 140px; }
.col-3 { left: 320px; min-width: 120px; }
.col-4 { left: 440px; min-width: 120px; text-align: right; }
.col-5 { left: 560px; min-width: 80px; }
.modal-footer {
padding: 14px 20px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
justify-content: flex-end;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96));
}
.btn-primary {
height: 38px;
border-radius: 10px;
border: 1px solid #030faa;
font-weight: 700;
font-size: 12px;
cursor: pointer;
padding: 0 14px;
background: linear-gradient(135deg, #1543ff, #030faa);
color: #fff;
box-shadow: 0 10px 20px rgba(3, 15, 170, 0.24);
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(3, 15, 170, 0.28);
filter: brightness(1.04);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.empty-state {
text-align: center;
padding: 24px;
font-weight: 700;
color: rgba(17, 18, 20, 0.6);
}
@keyframes popUp {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}

View File

@ -0,0 +1,41 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export type AnnualMonthValue = {
month: number;
label: string;
value: number | null;
};
export type AnnualRow = {
cliente: string;
linha: string;
item: string;
total: number;
parcelasLabel: string;
months: AnnualMonthValue[];
};
@Component({
selector: 'app-parcelamento-detalhamento-anual-modal',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './parcelamento-detalhamento-anual-modal.html',
styleUrls: ['./parcelamento-detalhamento-anual-modal.scss'],
})
export class ParcelamentoDetalhamentoAnualModalComponent {
@Input() open = false;
@Input() years: number[] = [];
@Input() selectedYear: number | null = null;
@Input() data: AnnualRow | null = null;
@Output() close = new EventEmitter<void>();
@Output() yearChange = new EventEmitter<number>();
onYearChange(value: unknown): void {
const year = Number(value);
if (!Number.isFinite(year)) return;
this.yearChange.emit(year);
}
}

View File

@ -0,0 +1,74 @@
<div class="filters-card" role="region" aria-label="Filtros de parcelamentos">
<div class="filters-head">
<div class="filters-title-wrap">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros da listagem</span>
</div>
<small>Use os campos abaixo para refinar a consulta sem alterar os dados.</small>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="apply.emit()" [disabled]="loading">
<i class="bi bi-check2-circle"></i>
Aplicar filtros
</button>
<button class="btn-ghost" type="button" (click)="clear.emit()" [disabled]="loading">
<i class="bi bi-eraser"></i>
Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field">
<label>AnoRef</label>
<input type="number" placeholder="Ano" [(ngModel)]="filters.anoRef" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Linha</label>
<input type="text" placeholder="Ex: 11999999999" [(ngModel)]="filters.linha" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Cliente</label>
<input type="text" placeholder="Nome do cliente" [(ngModel)]="filters.cliente" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Competencia</label>
<div class="competencia-row">
<input type="number" placeholder="Ano" [(ngModel)]="filters.competenciaAno" [disabled]="loading" />
<app-select
class="select-glass"
size="sm"
[options]="monthOptions"
labelKey="label"
valueKey="value"
placeholder="Mes"
[(ngModel)]="filters.competenciaMes"
[disabled]="loading"
></app-select>
</div>
<small class="hint warn" *ngIf="competenciaInvalid">Informe ano e mes.</small>
</div>
</div>
<div class="filters-meta">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[(ngModel)]="filters.search"
(ngModelChange)="searchChange.emit(filters.search)" />
</div>
<div class="filter-chips" *ngIf="activeChips && activeChips.length">
<span class="chip" *ngFor="let chip of activeChips">
<strong>{{ chip.label }}:</strong> {{ chip.value }}
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,300 @@
:host {
display: block;
min-width: 0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
.filters-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
padding: 16px;
display: grid;
gap: 14px;
background: rgba(255, 255, 255, 0.95);
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
overflow: hidden;
}
.filters-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters-head > * {
min-width: 0;
}
.filters-title-wrap {
display: grid;
gap: 6px;
flex: 1 1 360px;
min-width: 0;
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.filters-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--pg-text, #0f172a);
font-weight: 800;
i {
color: var(--pg-primary, #1f4fd6);
}
}
.filters-actions {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: var(--pg-radius-sm, 10px);
border: 1px solid transparent;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-primary {
color: #fff;
border-color: var(--pg-primary-strong, #153caa);
background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa));
box-shadow: 0 10px 20px rgba(31, 79, 214, 0.25);
}
.btn-ghost {
color: var(--pg-text, #0f172a);
background: #fff;
border-color: var(--pg-border-strong, #c8d4e4);
}
.btn-primary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--pg-primary, #1f4fd6);
color: var(--pg-primary-strong, #153caa);
}
.filters-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--pg-text-soft, #64748b);
}
input {
width: 100%;
height: 40px;
border-radius: var(--pg-radius-sm, 10px);
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
padding: 0 12px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--pg-primary, #1f4fd6);
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
background: #f5f8fd;
color: var(--pg-text-soft, #64748b);
cursor: not-allowed;
}
}
}
.competencia-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.competencia-row > * {
min-width: 0;
width: 100%;
}
.filters-meta {
border-top: 1px dashed var(--pg-border, #dbe3ef);
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.search-box {
display: inline-flex;
align-items: center;
gap: 8px;
width: min(420px, 100%);
min-width: 0;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: var(--pg-radius-sm, 10px);
background: #fff;
padding: 0 12px;
height: 40px;
i {
color: var(--pg-text-soft, #64748b);
}
input {
width: 100%;
border: none;
outline: none;
font-size: 13px;
color: var(--pg-text, #0f172a);
background: transparent;
}
&:focus-within {
border-color: var(--pg-primary, #1f4fd6);
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.filter-chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid rgba(31, 79, 214, 0.2);
background: rgba(31, 79, 214, 0.1);
padding: 4px 10px;
color: var(--pg-text-muted, #475569);
font-size: 11px;
font-weight: 700;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
strong {
color: var(--pg-primary-strong, #153caa);
}
}
.hint {
font-size: 12px;
color: var(--pg-text-muted, #475569);
}
.hint.warn {
color: var(--pg-warning, #b4690e);
font-weight: 700;
}
.select-glass {
display: block;
width: 100%;
min-width: 0;
background: #fff;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: var(--pg-radius-sm, 10px);
color: var(--pg-text, #0f172a);
font-weight: 700;
}
@media (max-width: 1100px) {
.filters-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.filters-actions {
width: 100%;
}
.filters-actions .btn-primary,
.filters-actions .btn-ghost {
flex: 1;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filters-meta {
align-items: stretch;
}
.search-box {
width: 100%;
min-width: 0;
}
.filter-chips {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,36 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
export type MonthOption = { value: number; label: string };
export type ParcelamentosFiltersModel = {
anoRef: string;
linha: string;
cliente: string;
competenciaAno: string;
competenciaMes: number | '';
search: string;
};
export type FilterChip = { label: string; value: string };
@Component({
selector: 'app-parcelamentos-filters',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './parcelamentos-filters.html',
styleUrls: ['./parcelamentos-filters.scss'],
})
export class ParcelamentosFiltersComponent {
@Input() filters!: ParcelamentosFiltersModel;
@Input() monthOptions: MonthOption[] = [];
@Input() loading = false;
@Input() competenciaInvalid = false;
@Input() activeChips: FilterChip[] = [];
@Output() apply = new EventEmitter<void>();
@Output() clear = new EventEmitter<void>();
@Output() searchChange = new EventEmitter<string>();
}

View File

@ -0,0 +1,14 @@
<div class="parcelamentos-kpis" *ngIf="cards?.length">
<div class="kpi-card" *ngFor="let k of cards">
<span class="kpi-label">{{ k?.label }}</span>
<span
class="kpi-value"
[class.tone-brand]="k?.tone === 'brand'"
[class.tone-success]="k?.tone === 'success'"
[class.tone-danger]="k?.tone === 'danger'"
[class.tone-info]="k?.tone === 'info'">
{{ k?.value }}
</span>
<span class="kpi-hint" *ngIf="k?.hint">{{ k?.hint }}</span>
</div>
</div>

View File

@ -0,0 +1,57 @@
:host {
display: block;
min-width: 0;
}
.parcelamentos-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(196px, 1fr));
gap: 12px;
}
.kpi-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
background: linear-gradient(180deg, #ffffff, #f8fbff);
padding: 14px 15px;
display: grid;
gap: 6px;
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
}
.kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--pg-text-soft, #64748b);
}
.kpi-value {
font-size: 1.2rem;
line-height: 1.2;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
.kpi-hint {
font-size: 12px;
font-weight: 600;
color: var(--pg-text-muted, #475569);
}
.tone-brand {
color: var(--pg-primary, #1f4fd6);
}
.tone-success {
color: #1c7a3e;
}
.tone-danger {
color: #b42323;
}
.tone-info {
color: #1f4fd6;
}

View File

@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ParcelamentoKpi = {
label: string;
value: string;
hint?: string;
tone?: 'brand' | 'success' | 'danger' | 'info' | 'muted';
};
@Component({
selector: 'app-parcelamentos-kpis',
standalone: true,
imports: [CommonModule],
templateUrl: './parcelamentos-kpis.html',
styleUrls: ['./parcelamentos-kpis.scss'],
})
export class ParcelamentosKpisComponent {
@Input() cards: ParcelamentoKpi[] = [];
}

View File

@ -0,0 +1,161 @@
<div class="table-card">
<div class="table-head">
<div class="table-head-left">
<h3>Carteira de Parcelamentos</h3>
<small>Visualizacao paginada com filtros e acoes por registro</small>
</div>
<div class="segmented" role="tablist" aria-label="Segmentos de parcelamentos">
<button
type="button"
class="segment-btn"
*ngFor="let s of segments"
[class.active]="segment === s.key"
(click)="segmentChange.emit(s.key)">
{{ s.label }}
<span class="count">{{ segmentCounts[s.key] || 0 }}</span>
</button>
</div>
</div>
<div class="table-state loading" *ngIf="loading">
<div class="state-icon"><i class="bi bi-hourglass-split"></i></div>
<div class="state-copy">
<strong>Carregando parcelamentos...</strong>
<span>Aguarde enquanto os dados sao atualizados.</span>
</div>
<div class="skeleton-group">
<div class="skeleton-row" *ngFor="let _ of skeletonRows">
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
</div>
</div>
</div>
<div class="table-state error" *ngIf="!loading && errorMessage">
<div class="state-icon"><i class="bi bi-exclamation-triangle"></i></div>
<div class="state-copy">
<strong>Falha ao carregar dados</strong>
<span>{{ errorMessage }}</span>
</div>
</div>
<div class="table-state empty" *ngIf="!loading && !errorMessage && items.length === 0">
<div class="state-icon"><i class="bi bi-inbox"></i></div>
<div class="state-copy">
<strong>Nenhum parcelamento encontrado</strong>
<span>Altere os filtros para tentar novamente.</span>
</div>
</div>
<div class="parcelamentos-table-wrap" *ngIf="!loading && !errorMessage && items.length">
<table class="table-modern">
<thead>
<tr>
<th class="col-ano nowrap">Ano ref.</th>
<th class="col-linha nowrap">Linha</th>
<th class="col-cliente">Cliente</th>
<th class="col-status nowrap">Status</th>
<th class="col-parcela nowrap">Parcela atual</th>
<th class="col-valor text-end nowrap">Valor cheio</th>
<th class="col-valor text-end nowrap">Desconto</th>
<th class="col-valor text-end nowrap">Valor c/ desconto</th>
<th class="col-acoes text-center nowrap">Acoes</th>
</tr>
</thead>
<tbody>
<tr class="table-row" *ngFor="let row of items; trackBy: trackById">
<td class="text-muted fw-bold col-ano nowrap">{{ row.anoRef ?? '-' }}</td>
<td class="text-blue fw-black col-linha nowrap">{{ row.linha || '-' }}</td>
<td class="col-cliente">{{ row.cliente || '-' }}</td>
<td class="col-status nowrap">
<span class="status-pill" [class]="'status-pill status-' + row.status">
{{ row.statusLabel }}
</span>
</td>
<td class="col-parcela nowrap">{{ row.progressLabel || '-' }}</td>
<td class="col-valor text-end money-strong nowrap">
{{ row.valorCheioNumber === null || row.valorCheioNumber === undefined
? '-' : (row.valorCheioNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-valor text-end text-danger nowrap">
{{ row.descontoNumber === null || row.descontoNumber === undefined
? '-' : (row.descontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-valor text-end money-strong nowrap">
{{ row.valorComDescontoNumber === null || row.valorComDescontoNumber === undefined
? '-' : (row.valorComDescontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-acoes text-center nowrap">
<div class="action-group">
<button
class="btn-icon"
type="button"
title="Detalhes"
aria-label="Detalhes"
(click)="detail.emit(row)">
<i class="bi bi-eye"></i>
</button>
<button
class="btn-icon ghost"
type="button"
title="Editar"
aria-label="Editar"
(click)="edit.emit(row)">
<i class="bi bi-pencil"></i>
</button>
<button
class="btn-icon danger"
type="button"
title="Excluir"
aria-label="Excluir"
*ngIf="isAdmin"
(click)="remove.emit(row)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="page-info">
Mostrando {{ pageStart }}-{{ pageEnd }} de {{ total }}
</div>
<div class="pagination" role="navigation" aria-label="Paginacao da tabela">
<button class="btn-ghost icon-only" type="button" (click)="pageChange.emit(page - 1)" [disabled]="page <= 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="btn-page"
type="button"
*ngFor="let p of pageNumbers"
[class.active]="p === page"
(click)="pageChange.emit(p)">
{{ p }}
</button>
<button class="btn-ghost icon-only" type="button" (click)="pageChange.emit(page + 1)" [disabled]="page >= (pageNumbers[pageNumbers.length - 1] || page)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="page-size">
<span>Itens por pag</span>
<select
class="select-glass"
[ngModel]="pageSize"
(ngModelChange)="pageSizeChange.emit($event)">
<option *ngFor="let size of pageSizeOptions" [value]="size">{{ size }}</option>
</select>
</div>
</div>
</div>

View File

@ -0,0 +1,499 @@
:host {
display: block;
min-width: 0;
}
.table-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
overflow: hidden;
background: #fff;
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
}
.table-head {
padding: 14px 16px;
border-bottom: 1px solid var(--pg-border, #dbe3ef);
background: linear-gradient(180deg, #f8fbff, #ffffff 75%);
display: grid;
gap: 12px;
}
.table-head-left {
display: grid;
gap: 4px;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.segmented {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.segment-btn {
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: 999px;
background: #fff;
color: var(--pg-text-muted, #475569);
padding: 7px 12px;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 7px;
cursor: pointer;
transition: all 0.2s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.segment-btn:hover {
border-color: var(--pg-primary, #1f4fd6);
color: var(--pg-primary-strong, #153caa);
}
.segment-btn.active {
background: rgba(31, 79, 214, 0.12);
border-color: rgba(31, 79, 214, 0.3);
color: var(--pg-primary-strong, #153caa);
}
.segment-btn .count {
border-radius: 999px;
padding: 2px 7px;
font-size: 11px;
font-weight: 800;
background: rgba(15, 23, 42, 0.08);
color: var(--pg-text-muted, #475569);
}
.table-state {
padding: 24px 16px;
display: grid;
gap: 12px;
justify-items: center;
text-align: center;
}
.state-icon {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid var(--pg-border, #dbe3ef);
background: var(--pg-surface-alt, #f8fafc);
color: var(--pg-text-muted, #475569);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
}
.state-copy {
display: grid;
gap: 4px;
strong {
color: var(--pg-text, #0f172a);
font-size: 0.92rem;
font-weight: 800;
}
span {
color: var(--pg-text-soft, #64748b);
font-size: 0.82rem;
font-weight: 600;
}
}
.table-state.error .state-icon {
color: var(--pg-danger, #c52929);
border-color: rgba(197, 41, 41, 0.32);
background: rgba(197, 41, 41, 0.08);
}
.table-state.empty .state-icon {
color: var(--pg-warning, #b4690e);
border-color: rgba(180, 105, 14, 0.32);
background: rgba(180, 105, 14, 0.1);
}
.skeleton-group {
width: min(760px, 100%);
display: grid;
gap: 8px;
}
.skeleton-row {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr 1fr;
}
.skeleton-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, #e6edf8, #dbe6f3, #e6edf8);
background-size: 240px 100%;
animation: shimmer 1.3s infinite linear;
}
.parcelamentos-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.table-modern {
width: max-content;
min-width: 1120px;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 11px 10px;
border-bottom: 1px solid var(--pg-border-strong, #c8d4e4);
background: #f5f9ff;
color: var(--pg-text-soft, #64748b);
font-size: 0.67rem;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 800;
white-space: nowrap;
text-align: left;
}
tbody tr {
transition: background 0.18s ease;
}
tbody tr:nth-child(even) {
background: #f9fbff;
}
tbody tr:hover {
background: rgba(31, 79, 214, 0.08);
}
td {
padding: 11px 10px;
border-bottom: 1px solid #e9eef6;
font-size: 0.84rem;
color: var(--pg-text, #0f172a);
white-space: nowrap;
text-align: left;
vertical-align: middle;
}
}
.table-row {
cursor: default;
}
.nowrap {
white-space: nowrap;
}
.col-ano {
width: 90px;
}
.col-linha {
width: 138px;
}
.col-cliente {
width: 260px;
max-width: 260px;
}
.col-status {
width: 110px;
}
.col-parcela {
width: 130px;
}
.col-valor {
width: 150px;
text-align: right;
}
.col-acoes {
width: 152px;
min-width: 152px;
text-align: center;
}
.table-modern thead .col-ano,
.table-modern thead .col-linha,
.table-modern thead .col-cliente,
.table-modern thead .col-status,
.table-modern thead .col-parcela,
.table-modern thead .col-acoes,
.table-modern tbody .col-ano,
.table-modern tbody .col-linha,
.table-modern tbody .col-cliente,
.table-modern tbody .col-status,
.table-modern tbody .col-parcela,
.table-modern tbody .col-acoes {
text-align: center;
}
.text-end {
text-align: right;
}
.text-center {
text-align: center;
}
.text-blue {
color: var(--pg-primary-strong, #153caa);
}
.text-danger {
color: var(--pg-danger, #c52929);
}
.text-muted {
color: var(--pg-text-muted, #475569);
}
.fw-bold {
font-weight: 700;
}
.fw-black {
font-weight: 800;
}
.money-strong {
color: var(--pg-primary, #1f4fd6);
font-weight: 800;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid #d5dfef;
background: #f2f6fc;
color: #334155;
font-size: 11px;
font-weight: 800;
}
.status-ativos {
background: rgba(28, 122, 62, 0.14);
color: #1c7a3e;
border-color: rgba(28, 122, 62, 0.28);
}
.status-futuros {
background: rgba(31, 79, 214, 0.12);
color: #1f4fd6;
border-color: rgba(31, 79, 214, 0.28);
}
.status-finalizados {
background: rgba(197, 41, 41, 0.12);
color: #b42323;
border-color: rgba(197, 41, 41, 0.25);
}
.action-group {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: nowrap;
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid #cfd9e9;
border-radius: 10px;
background: #eff4ff;
color: var(--pg-primary-strong, #153caa);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--pg-primary, #1f4fd6);
background: #e3edff;
}
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.btn-icon.ghost {
background: #f4f6fb;
color: #475569;
}
.btn-icon.danger {
background: rgba(197, 41, 41, 0.12);
color: #b42323;
border-color: rgba(197, 41, 41, 0.22);
}
.table-footer {
border-top: 1px solid var(--pg-border, #dbe3ef);
background: #fff;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page-info {
color: var(--pg-text-muted, #475569);
font-size: 12px;
font-weight: 700;
}
.pagination {
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-ghost.icon-only,
.btn-page {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.btn-page.active {
color: #fff;
border-color: var(--pg-primary-strong, #153caa);
background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa));
}
.page-size {
display: inline-flex;
align-items: center;
gap: 8px;
span {
color: var(--pg-text-soft, #64748b);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
}
}
.select-glass {
min-width: 76px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
font-weight: 700;
padding: 0 8px;
}
@keyframes shimmer {
0% {
background-position: -120px 0;
}
100% {
background-position: 120px 0;
}
}
@media (max-width: 1180px) {
.table-modern {
min-width: 1020px;
}
.col-cliente {
width: 210px;
max-width: 210px;
}
}
@media (max-width: 760px) {
.table-head,
.table-footer {
padding-left: 12px;
padding-right: 12px;
}
.segmented {
width: 100%;
}
.segment-btn {
flex: 1;
justify-content: space-between;
min-width: 0;
}
.table-footer {
justify-content: center;
}
.page-size {
width: 100%;
justify-content: center;
}
}

View File

@ -0,0 +1,66 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ParcelamentoListItem } from '../../../../services/parcelamentos.service';
export type ParcelamentoSegment = 'todos' | 'ativos' | 'futuros' | 'finalizados';
export type ParcelamentoViewItem = ParcelamentoListItem & {
status: 'ativos' | 'futuros' | 'finalizados';
statusLabel: string;
progressLabel: string;
valorParcela?: number | null;
valorCheioNumber?: number | null;
descontoNumber?: number | null;
valorComDescontoNumber?: number | null;
};
@Component({
selector: 'app-parcelamentos-table',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './parcelamentos-table.html',
styleUrls: ['./parcelamentos-table.scss'],
})
export class ParcelamentosTableComponent {
@Input() items: ParcelamentoViewItem[] = [];
@Input() loading = false;
@Input() errorMessage = '';
@Input() isAdmin = false;
@Input() segment: ParcelamentoSegment = 'todos';
@Input() segmentCounts: Record<ParcelamentoSegment, number> = {
todos: 0,
ativos: 0,
futuros: 0,
finalizados: 0,
};
@Input() page = 1;
@Input() pageNumbers: number[] = [];
@Input() pageStart = 0;
@Input() pageEnd = 0;
@Input() total = 0;
@Input() pageSize = 10;
@Input() pageSizeOptions: number[] = [];
@Output() segmentChange = new EventEmitter<ParcelamentoSegment>();
@Output() detail = new EventEmitter<ParcelamentoViewItem>();
@Output() edit = new EventEmitter<ParcelamentoViewItem>();
@Output() remove = new EventEmitter<ParcelamentoViewItem>();
@Output() pageChange = new EventEmitter<number>();
@Output() pageSizeChange = new EventEmitter<number>();
readonly segments: Array<{ key: ParcelamentoSegment; label: string }> = [
{ key: 'todos', label: 'Lista geral' },
{ key: 'ativos', label: 'Ativos' },
{ key: 'futuros', label: 'Futuros' },
{ key: 'finalizados', label: 'Finalizados' },
];
skeletonRows = Array.from({ length: 6 });
trackById(_: number, item: ParcelamentoViewItem): string {
return item.id;
}
}

View File

@ -0,0 +1,251 @@
<section class="parcelamentos-page">
<div class="container-geral-responsive">
<div class="parcelamentos-shell">
<header class="page-header">
<div class="page-header-main">
<div class="title-group">
<span class="title-badge"><i class="bi bi-wallet2"></i> PARCELAMENTOS</span>
<div class="header-title">
<h2>Gestao de Parcelamentos</h2>
<p>Painel administrativo de parcelas de aparelhos e contratos</p>
</div>
</div>
<div class="header-actions">
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-repeat"></i> Atualizar
</button>
<button class="btn-primary" type="button" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i> Novo Parcelamento
</button>
</div>
</div>
<div class="header-highlights" aria-label="Resumo da listagem">
<div class="highlight-card">
<span>Total de registros</span>
<strong>{{ total }}</strong>
</div>
<div class="highlight-card">
<span>Pagina atual</span>
<strong>{{ page }} de {{ totalPages }}</strong>
</div>
<div class="highlight-card">
<span>Segmento ativo</span>
<strong>
{{ activeSegment === 'todos' ? 'Lista geral' : (activeSegment === 'ativos' ? 'Ativos' : (activeSegment === 'futuros' ? 'Futuros' : 'Finalizados')) }}
</strong>
</div>
</div>
</header>
<app-parcelamentos-kpis [cards]="kpiCards"></app-parcelamentos-kpis>
<app-parcelamentos-filters
[filters]="filters"
[monthOptions]="monthOptions"
[loading]="loading"
[competenciaInvalid]="competenciaInvalid"
[activeChips]="activeChips"
(apply)="applyFilters()"
(clear)="clearFilters()"
(searchChange)="onSearchChange($event)">
</app-parcelamentos-filters>
<app-parcelamentos-table
[items]="viewItems"
[loading]="loading"
[errorMessage]="errorMessage"
[segment]="activeSegment"
[segmentCounts]="segmentCounts"
[page]="page"
[pageNumbers]="pageNumbers"
[pageStart]="pageStart"
[pageEnd]="pageEnd"
[total]="total"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[isAdmin]="isAdmin"
(segmentChange)="setSegment($event)"
(detail)="openDetails($event)"
(edit)="openEdit($event)"
(remove)="openDelete($event)"
(pageChange)="goToPage($event)"
(pageSizeChange)="onPageSizeChange($event)">
</app-parcelamentos-table>
</div>
</div>
</section>
<!-- Modal detalhes -->
<div class="lg-backdrop" *ngIf="detailOpen" (click)="closeDetails()"></div>
<div class="lg-modal" *ngIf="detailOpen">
<div class="lg-modal-card parcelamento-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-card-list"></i></span>
<span>Detalhes do Parcelamento</span>
</div>
<div class="modal-actions">
<button class="btn-icon" type="button" (click)="closeDetails()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="detail-state" *ngIf="detailLoading && !selectedDetail">
<div class="spinner-border text-brand" role="status"></div>
<span>Carregando detalhes...</span>
</div>
<div class="detail-state error" *ngIf="!detailLoading && detailError && !selectedDetail">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ detailError }}</span>
</div>
<ng-container *ngIf="selectedDetail as detail">
<div class="detail-grid">
<div class="detail-card">
<small>Cliente</small>
<span class="detail-strong">{{ detail.cliente || '-' }}</span>
</div>
<div class="detail-card">
<small>Linha</small>
<span class="detail-strong text-blue">{{ detail.linha || '-' }}</span>
</div>
<div class="detail-card">
<small>AnoRef</small>
<span>{{ detail.anoRef ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Item</small>
<span>{{ detail.item ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Qt Parcelas</small>
<span>{{ displayQtParcelas(detail) }}</span>
</div>
<div class="detail-card">
<small>Parcela Atual</small>
<span class="detail-strong">{{ detail.parcelaAtual ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Total Parcelas</small>
<span>{{ detail.totalParcelas ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Status</small>
<span class="status-pill">{{ detailStatus }}</span>
</div>
<div class="detail-card">
<small>Valor Cheio</small>
<span>{{ formatMoney(detail.valorCheio) }}</span>
</div>
<div class="detail-card">
<small>Desconto</small>
<span class="text-danger">{{ formatMoney(detail.desconto) }}</span>
</div>
<div class="detail-card highlight">
<small>Valor com Desconto</small>
<span class="detail-strong money-strong">{{ formatMoney(detail.valorComDesconto) }}</span>
</div>
</div>
<div class="annual-section">
<div class="annual-head">
<div class="section-title">
<i class="bi bi-table"></i>
<span>Detalhamento anual</span>
</div>
</div>
<div class="annual-table-shell" *ngIf="annualRows.length > 0; else annualEmpty">
<table class="table-modern annual-table">
<thead>
<tr>
<th class="sticky-col col-1">Ano</th>
<th class="sticky-col col-2 text-end">Total</th>
<th *ngFor="let m of annualMonthHeaders" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of annualRows">
<td class="sticky-col col-1">{{ row.year }}</td>
<td class="sticky-col col-2 text-end">{{ row.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td *ngFor="let m of row.months" class="text-end">
{{ m.value !== null && m.value !== undefined ? (m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR') : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #annualEmpty>
<div class="annual-empty">
Sem dados anuais.
</div>
</ng-template>
</div>
</ng-container>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>
<app-parcelamento-create-modal
[open]="createOpen"
[model]="createModel"
[monthOptions]="monthOptions"
[loading]="createSaving"
[errorMessage]="createError"
title="Novo Parcelamento"
submitLabel="Salvar"
(close)="closeCreateModal()"
(save)="saveNewParcelamento($event)">
</app-parcelamento-create-modal>
<app-parcelamento-create-modal
*ngIf="editOpen && editModel"
[open]="editOpen"
[model]="editModel"
[monthOptions]="monthOptions"
[loading]="editSaving"
[errorMessage]="editError"
title="Editar Parcelamento"
submitLabel="Atualizar"
(close)="closeEditModal()"
(save)="saveEditParcelamento($event)">
</app-parcelamento-create-modal>
<!-- Delete modal -->
<div class="lg-backdrop" *ngIf="deleteOpen"></div>
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-compact" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Parcelamento
</div>
<button class="btn-icon" type="button" (click)="cancelDelete()" aria-label="Fechar modal de exclusao">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o parcelamento <strong>{{ deleteTarget?.linha }}</strong>?</p>
<small class="text-danger" *ngIf="deleteError">{{ deleteError }}</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="cancelDelete()">Cancelar</button>
<button class="btn-danger" type="button" [disabled]="deleteLoading" (click)="confirmDelete()">
{{ deleteLoading ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,614 @@
:host {
--pg-font-sans: 'IBM Plex Sans', 'Source Sans 3', 'Manrope', 'Segoe UI', sans-serif;
--pg-primary: #1f4fd6;
--pg-primary-strong: #153caa;
--pg-primary-soft: rgba(31, 79, 214, 0.12);
--pg-primary-soft-2: rgba(31, 79, 214, 0.18);
--pg-text: #0f172a;
--pg-text-muted: #475569;
--pg-text-soft: #64748b;
--pg-bg: #f3f6fb;
--pg-surface: #ffffff;
--pg-surface-alt: #f8fafc;
--pg-border: #dbe3ef;
--pg-border-strong: #c8d4e4;
--pg-warning: #b4690e;
--pg-warning-soft: rgba(180, 105, 14, 0.14);
--pg-danger: #c52929;
--pg-danger-soft: rgba(197, 41, 41, 0.12);
--pg-success: #1c7a3e;
--pg-radius-sm: 10px;
--pg-radius-md: 14px;
--pg-radius-lg: 18px;
--pg-shadow-sm: 0 8px 18px rgba(15, 23, 42, 0.08);
--pg-shadow-md: 0 16px 32px rgba(15, 23, 42, 0.12);
--pg-shadow-lg: 0 24px 56px rgba(15, 23, 42, 0.25);
--pg-focus-ring: 0 0 0 3px rgba(31, 79, 214, 0.22);
--brand: var(--pg-primary);
--blue: var(--pg-primary-strong);
--text: var(--pg-text);
--muted: var(--pg-text-muted);
--focus-ring: var(--pg-focus-ring);
display: block;
color: var(--pg-text);
font-family: var(--pg-font-sans);
}
.parcelamentos-page {
min-height: 100vh;
padding: 0 12px 72px;
display: flex;
justify-content: center;
position: relative;
background:
radial-gradient(1100px 450px at 10% -10%, rgba(31, 79, 214, 0.11), transparent 60%),
radial-gradient(900px 420px at 100% 0%, rgba(30, 64, 175, 0.07), transparent 58%),
linear-gradient(180deg, #f9fbff 0%, var(--pg-bg) 75%);
}
.container-geral-responsive {
width: 100%;
max-width: 1280px;
position: relative;
z-index: 1;
margin-top: 28px;
}
.parcelamentos-shell {
display: grid;
gap: 16px;
min-width: 0;
}
.parcelamentos-shell > * {
min-width: 0;
}
.page-header {
display: grid;
gap: 14px;
border: 1px solid var(--pg-border);
border-radius: var(--pg-radius-lg);
padding: 18px 20px;
background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.94));
box-shadow: var(--pg-shadow-sm);
}
.page-header-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title-group {
display: grid;
gap: 8px;
}
.title-badge {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 6px 11px;
border-radius: 999px;
background: var(--pg-primary-soft);
border: 1px solid var(--pg-primary-soft-2);
color: var(--pg-primary-strong);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
i {
color: var(--pg-primary);
}
}
.header-title h2 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.65rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.header-title p {
margin: 4px 0 0;
font-size: 0.92rem;
color: var(--pg-text-muted);
font-weight: 600;
}
.header-actions {
display: inline-flex;
gap: 10px;
flex-wrap: wrap;
}
.header-highlights {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.highlight-card {
border: 1px solid var(--pg-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.84);
padding: 10px 12px;
display: grid;
gap: 4px;
span {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--pg-text-soft);
font-weight: 700;
}
strong {
font-size: 0.95rem;
color: var(--pg-text);
font-weight: 800;
}
}
.btn-primary,
.btn-ghost,
.btn-danger {
height: 40px;
border-radius: var(--pg-radius-sm);
border: 1px solid transparent;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring);
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-primary {
color: #fff;
background: linear-gradient(140deg, var(--pg-primary), var(--pg-primary-strong));
border-color: var(--pg-primary-strong);
box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28);
}
.btn-ghost {
color: var(--pg-text);
background: #fff;
border-color: var(--pg-border-strong);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
}
.btn-danger {
color: #fff;
background: linear-gradient(145deg, #cf3131, #a91f1f);
border-color: #a91f1f;
box-shadow: 0 10px 20px rgba(169, 31, 31, 0.24);
}
.btn-primary:hover,
.btn-ghost:hover,
.btn-danger:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--pg-primary);
color: var(--pg-primary-strong);
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%),
rgba(15, 23, 42, 0.6);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1180px, 98vw);
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--pg-surface);
border: 1px solid var(--pg-border);
border-radius: 18px;
box-shadow: var(--pg-shadow-lg);
animation: pop-up 0.24s ease;
}
.modal-compact {
width: min(560px, 96vw);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--pg-border);
background: linear-gradient(180deg, rgba(244, 248, 255, 0.96), rgba(255, 255, 255, 0.96));
}
.modal-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 800;
color: var(--pg-text);
}
.icon-bg {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--pg-primary-soft);
color: var(--pg-primary-strong);
}
.icon-bg.danger-soft {
background: var(--pg-danger-soft);
color: var(--pg-danger);
}
.modal-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid var(--pg-border-strong);
border-radius: 10px;
background: #fff;
color: var(--pg-text-soft);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--pg-primary-strong);
border-color: var(--pg-primary);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring);
}
}
.modal-body {
padding: 18px;
background: linear-gradient(180deg, #f8fbff, #ffffff 80%);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.modal-footer {
padding: 14px 18px;
border-top: 1px solid var(--pg-border);
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
background: #fff;
}
.detail-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 180px;
color: var(--pg-text-muted);
font-weight: 700;
}
.detail-state.error {
color: var(--pg-danger);
}
.text-brand {
color: var(--pg-primary);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.detail-card {
border: 1px solid var(--pg-border);
border-radius: 12px;
padding: 11px 12px;
background: #fff;
display: grid;
gap: 6px;
color: var(--pg-text-muted);
small {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.66rem;
font-weight: 800;
}
span {
color: var(--pg-text);
font-size: 0.88rem;
font-weight: 700;
}
}
.detail-card.highlight {
grid-column: span 2;
background: linear-gradient(180deg, #ffffff, #f4f8ff);
}
.detail-strong {
font-weight: 800;
}
.text-blue {
color: var(--pg-primary-strong);
}
.money-strong {
color: var(--pg-primary);
}
.text-danger {
color: var(--pg-danger);
}
.status-pill {
width: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--pg-primary-soft-2);
background: var(--pg-primary-soft);
color: var(--pg-primary-strong);
font-size: 0.74rem;
font-weight: 800;
}
.annual-section {
margin-top: 16px;
border: 1px solid var(--pg-border);
border-radius: 14px;
padding: 12px;
background: #fff;
display: grid;
gap: 10px;
}
.annual-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.section-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 800;
color: var(--pg-text);
}
.annual-table-shell {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--pg-border);
border-radius: 12px;
}
.annual-table {
width: max-content;
min-width: 1220px;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
th,
td {
border-bottom: 1px solid #e7edf6;
padding: 8px 10px;
white-space: nowrap;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--pg-text-soft);
background: #f6f9fe;
font-size: 10px;
font-weight: 800;
}
}
.annual-section .sticky-col {
position: sticky;
left: 0;
z-index: 3;
background: #fff;
box-shadow: 2px 0 0 #eef3fb;
}
.annual-section .col-1 {
left: 0;
min-width: 90px;
}
.annual-section .col-2 {
left: 90px;
min-width: 128px;
}
.annual-empty {
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--pg-border-strong);
border-radius: 10px;
font-size: 0.84rem;
font-weight: 700;
color: var(--pg-text-muted);
background: var(--pg-surface-alt);
}
.confirm-delete {
min-height: 150px;
display: grid;
align-content: center;
justify-items: center;
gap: 10px;
text-align: center;
color: var(--pg-text-muted);
p {
margin: 0;
color: var(--pg-text);
font-weight: 600;
}
}
.confirm-icon {
width: 54px;
height: 54px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--pg-danger);
border: 1px solid rgba(197, 41, 41, 0.22);
background: rgba(197, 41, 41, 0.1);
font-size: 1.15rem;
}
@keyframes pop-up {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 1100px) {
.header-highlights {
grid-template-columns: 1fr;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.container-geral-responsive {
margin-top: 18px;
}
.page-header,
.modal-body,
.modal-header,
.modal-footer {
padding-left: 14px;
padding-right: 14px;
}
.header-actions {
width: 100%;
}
.header-actions .btn-primary,
.header-actions .btn-ghost {
flex: 1;
}
.detail-grid {
grid-template-columns: 1fr;
}
.detail-card.highlight {
grid-column: span 1;
}
.modal-footer {
justify-content: stretch;
}
.modal-footer .btn-primary,
.modal-footer .btn-ghost,
.modal-footer .btn-danger {
flex: 1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
<section class="perfil-page">
<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="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-person-circle"></i> PERFIL
</div>
<div class="header-title">
<h5 class="title mb-0">MEU PERFIL</h5>
<small class="subtitle">Atualize seus dados e credenciais de acesso</small>
</div>
<div class="header-actions"></div>
</div>
</div>
<div class="geral-body">
<div class="perfil-sections">
<div class="perfil-section-card">
<div class="section-header">
<h2>Informação de perfil</h2>
</div>
<div class="form-alert error" *ngIf="profileError">
{{ profileError }}
</div>
<div class="form-alert success" *ngIf="profileSuccess">
{{ profileSuccess }}
</div>
<form class="profile-form" [formGroup]="profileForm" (ngSubmit)="onSaveProfile()" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="profileNome">Nome</label>
<input
id="profileNome"
type="text"
class="form-control"
formControlName="nome"
/>
<small class="field-error" *ngIf="hasProfileFieldError('nome', 'required')">
Nome é obrigatório.
</small>
<small class="field-error" *ngIf="hasProfileFieldError('nome', 'minlength')">
Nome deve ter pelo menos 2 caracteres.
</small>
</div>
<div class="form-field">
<label for="profileEmail">Email</label>
<input
id="profileEmail"
type="email"
class="form-control"
formControlName="email"
/>
<small class="field-error" *ngIf="hasProfileFieldError('email', 'required')">
Email é obrigatório.
</small>
<small class="field-error" *ngIf="hasProfileFieldError('email', 'email')">
Email inválido.
</small>
</div>
</div>
<div class="section-actions">
<button
type="submit"
class="btn btn-brand btn-sm"
[disabled]="loadingProfile || savingProfile || profileForm.invalid"
>
<span class="spinner-border spinner-border-sm me-2" *ngIf="savingProfile"></span>
{{ savingProfile ? 'SALVANDO...' : 'SALVAR' }}
</button>
</div>
</form>
</div>
<div class="perfil-section-card">
<div class="section-header">
<h2>Atualizar senha</h2>
</div>
<div class="form-alert error" *ngIf="passwordError">
{{ passwordError }}
</div>
<div class="form-alert success" *ngIf="passwordSuccess">
{{ passwordSuccess }}
</div>
<form class="profile-form" [formGroup]="passwordForm" (ngSubmit)="onChangePassword()" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="currentPassword">Credencial atual</label>
<input
id="currentPassword"
type="password"
class="form-control"
formControlName="credencialAtual"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('credencialAtual', 'required')">
Credencial atual é obrigatória.
</small>
</div>
<div class="form-field">
<label for="newPassword">Nova credencial</label>
<input
id="newPassword"
type="password"
class="form-control"
formControlName="novaCredencial"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('novaCredencial', 'required')">
Nova credencial é obrigatória.
</small>
<small class="field-error" *ngIf="hasPasswordFieldError('novaCredencial', 'minlength')">
Nova credencial deve ter pelo menos 8 caracteres.
</small>
</div>
<div class="form-field">
<label for="confirmNewPassword">Confirmar sua credencial nova</label>
<input
id="confirmNewPassword"
type="password"
class="form-control"
formControlName="confirmarNovaCredencial"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('confirmarNovaCredencial', 'required')">
Confirmação da nova credencial é obrigatória.
</small>
<small class="field-error" *ngIf="passwordMismatch">
A nova credencial e a confirmação precisam ser iguais.
</small>
</div>
</div>
<div class="section-actions">
<button type="submit" class="btn btn-brand btn-sm" [disabled]="savingPassword || passwordForm.invalid">
<span class="spinner-border spinner-border-sm me-2" *ngIf="savingPassword"></span>
{{ savingPassword ? 'SALVANDO...' : 'SALVAR' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,295 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754;
--danger-bg: rgba(220, 53, 69, 0.1);
--danger-text: #dc3545;
--radius-xl: 22px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.1);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
.perfil-page {
min-height: 100vh;
padding: 0 12px;
display: flex;
justify-content: center;
position: relative;
overflow-y: auto;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
}
.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: 0.45; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
.container-geral-responsive {
width: 100%;
max-width: 1180px;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 120px;
}
.geral-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
min-height: 80vh;
}
.geral-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2));
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
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-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.title {
font-size: 26px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
}
.subtitle {
color: var(--muted);
font-weight: 700;
}
.header-actions {
justify-self: end;
min-height: 1px;
}
.geral-body {
padding: 18px;
}
.perfil-sections {
display: grid;
gap: 14px;
}
.perfil-section-card {
background: #ffffff;
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(17, 18, 20, 0.06);
padding: 18px 20px;
}
.section-header {
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 1rem;
font-weight: 900;
color: var(--text);
letter-spacing: 0.02em;
text-transform: uppercase;
}
}
.form-alert {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
&.error {
background: var(--danger-bg);
border: 1px solid rgba(220, 53, 69, 0.2);
color: var(--danger-text);
}
&.success {
background: var(--success-bg);
border: 1px solid rgba(25, 135, 84, 0.2);
color: var(--success-text);
}
}
.profile-form {
display: grid;
gap: 12px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
label {
font-size: 0.78rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
}
.form-control {
height: 40px;
border-radius: 10px;
border: 1px solid rgba(17, 18, 20, 0.16);
background: rgba(255, 255, 255, 0.86);
color: var(--text);
font-size: 14px;
padding: 0 12px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
background: #fff;
}
}
.field-error {
color: var(--danger-text);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.section-actions {
display: flex;
justify-content: flex-end;
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
filter: brightness(1.05);
}
&:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
}
}
@media (max-width: 768px) {
.container-geral-responsive {
margin-top: 16px;
margin-bottom: 64px;
}
.header-row-top {
grid-template-columns: 1fr;
text-align: center;
gap: 10px;
}
.title-badge {
justify-self: center;
}
.header-actions {
display: none;
}
.geral-header {
padding: 14px 16px;
}
.geral-body {
padding: 12px;
}
.perfil-section-card {
padding: 14px;
}
.section-actions {
justify-content: stretch;
}
.section-actions .btn {
width: 100%;
}
}

View File

@ -0,0 +1,229 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
Validators
} from '@angular/forms';
import { ProfileService } from '../../services/profile.service';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-perfil',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './perfil.html',
styleUrls: ['./perfil.scss'],
})
export class Perfil implements OnInit {
profileForm: FormGroup;
passwordForm: FormGroup;
loadingProfile = false;
savingProfile = false;
savingPassword = false;
profileSuccess = '';
profileError = '';
passwordSuccess = '';
passwordError = '';
constructor(
private fb: FormBuilder,
private profileService: ProfileService,
private authService: AuthService
) {
this.profileForm = this.fb.group({
nome: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
});
this.passwordForm = this.fb.group(
{
credencialAtual: ['', [Validators.required]],
novaCredencial: ['', [Validators.required, Validators.minLength(8)]],
confirmarNovaCredencial: ['', [Validators.required]],
},
{ validators: this.passwordsMatchValidator }
);
}
ngOnInit(): void {
this.loadProfile();
}
onSaveProfile(): void {
if (this.savingProfile) return;
this.profileSuccess = '';
this.profileError = '';
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.savingProfile = true;
this.setProfileFormDisabled(true);
const payload = {
nome: String(this.profileForm.get('nome')?.value ?? '').trim(),
email: String(this.profileForm.get('email')?.value ?? '').trim(),
};
this.profileService.updateProfile(payload).subscribe({
next: (updated) => {
this.savingProfile = false;
this.setProfileFormDisabled(false);
this.profileSuccess = 'Perfil atualizado com sucesso.';
this.profileForm.patchValue({
nome: updated.nome ?? '',
email: updated.email ?? '',
});
this.profileForm.markAsPristine();
this.authService.updateUserProfile({
nome: updated.nome ?? '',
email: updated.email ?? '',
});
},
error: (err: HttpErrorResponse) => {
this.savingProfile = false;
this.setProfileFormDisabled(false);
this.profileError = this.extractErrorMessage(err, 'Não foi possível atualizar o perfil.');
},
});
}
onChangePassword(): void {
if (this.savingPassword) return;
this.passwordSuccess = '';
this.passwordError = '';
if (this.passwordForm.invalid) {
this.passwordForm.markAllAsTouched();
return;
}
this.savingPassword = true;
this.setPasswordFormDisabled(true);
const payload = {
credencialAtual: String(this.passwordForm.get('credencialAtual')?.value ?? ''),
novaCredencial: String(this.passwordForm.get('novaCredencial')?.value ?? ''),
confirmarNovaCredencial: String(this.passwordForm.get('confirmarNovaCredencial')?.value ?? ''),
};
this.profileService.changePassword(payload).subscribe({
next: () => {
this.savingPassword = false;
this.setPasswordFormDisabled(false);
this.passwordSuccess = 'Credencial atualizada com sucesso.';
this.passwordForm.reset();
},
error: (err: HttpErrorResponse) => {
this.savingPassword = false;
this.setPasswordFormDisabled(false);
this.passwordError = this.extractErrorMessage(err, 'Não foi possível atualizar a credencial.');
},
});
}
hasProfileFieldError(field: string, error?: string): boolean {
const control = this.profileForm.get(field);
if (!control) return false;
if (error) return !!(control.touched && control.hasError(error));
return !!(control.touched && control.invalid);
}
hasPasswordFieldError(field: string, error?: string): boolean {
const control = this.passwordForm.get(field);
if (!control) return false;
if (error) return !!(control.touched && control.hasError(error));
return !!(control.touched && control.invalid);
}
get passwordMismatch(): boolean {
const confirmTouched = this.passwordForm.get('confirmarNovaCredencial')?.touched;
return !!(confirmTouched && this.passwordForm.errors?.['passwordMismatch']);
}
private loadProfile(): void {
this.loadingProfile = true;
this.profileSuccess = '';
this.profileError = '';
this.setProfileFormDisabled(true);
this.profileService.getMe().subscribe({
next: (me) => {
this.loadingProfile = false;
this.profileForm.patchValue({
nome: me.nome ?? '',
email: me.email ?? '',
});
this.setProfileFormDisabled(false);
},
error: (err: HttpErrorResponse) => {
this.loadingProfile = false;
this.setProfileFormDisabled(false);
if (err.status === 404) {
const authProfile = this.authService.currentUserProfile;
if (authProfile) {
this.profileForm.patchValue({
nome: authProfile.nome ?? '',
email: authProfile.email ?? '',
});
}
}
this.profileError = this.extractErrorMessage(err, 'Não foi possível carregar os dados do perfil.');
},
});
}
private setProfileFormDisabled(disabled: boolean): void {
if (disabled) {
this.profileForm.disable({ emitEvent: false });
return;
}
this.profileForm.enable({ emitEvent: false });
}
private setPasswordFormDisabled(disabled: boolean): void {
if (disabled) {
this.passwordForm.disable({ emitEvent: false });
return;
}
this.passwordForm.enable({ emitEvent: false });
}
private extractErrorMessage(err: HttpErrorResponse, fallback: string): string {
if (err.status === 404) {
return 'API de perfil não encontrada (404). Reinicie/atualize o back-end com os novos endpoints de perfil.';
}
const apiError = err?.error;
if (Array.isArray(apiError?.errors) && apiError.errors.length) {
const msg = apiError.errors[0]?.message;
if (msg) return String(msg);
}
if (typeof apiError?.message === 'string' && apiError.message.trim()) {
return apiError.message.trim();
}
if (typeof apiError === 'string' && apiError.trim()) {
return apiError.trim();
}
return fallback;
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const nova = group.get('novaCredencial')?.value;
const confirmar = group.get('confirmarNovaCredencial')?.value;
if (!nova || !confirmar) return null;
return nova === confirmar ? null : { passwordMismatch: true };
}
}

View File

@ -78,7 +78,7 @@ export class Register {
this.isSubmitting = false; this.isSubmitting = false;
// Se você não quer manter "logado" após cadastrar: // Se você não quer manter "logado" após cadastrar:
localStorage.removeItem('token'); this.authService.logout();
await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.'); await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.');

View File

@ -0,0 +1,615 @@
<section class="resumo-page">
<div class="wrap">
<div class="resumo-container">
<div class="page-head" data-animate>
<div class="title-group">
<span class="badge-pill"><i class="bi bi-graph-up"></i> Dashboard</span>
<div class="flex-title">
<h2 class="page-title">Resumo Gerencial</h2>
<div class="status-wrapper">
<div class="status loading" *ngIf="loading">
<i class="bi bi-arrow-repeat spin"></i> Atualizando dados...
</div>
<div class="status error" *ngIf="!loading && errorMessage">
<i class="bi bi-exclamation-triangle"></i> {{ errorMessage }}
</div>
<div class="status success" *ngIf="!loading && !errorMessage">
<i class="bi bi-check-circle"></i> Atualizado
</div>
</div>
</div>
<p class="page-subtitle">Visão consolidada de performance, contratos e indicadores financeiros.</p>
</div>
<div class="header-actions">
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise" [class.spin]="loading"></i>
<span>Atualizar</span>
</button>
</div>
</div>
<div class="tab-bar" data-animate>
<button
type="button"
class="tab-btn"
*ngFor="let tab of tabs"
[class.active]="activeTab === tab.key"
(click)="setTab(tab.key)">
<i [class]="tab.icon"></i>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="tab-panel" *ngIf="activeTab === 'planos'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Planos & Contratos</h3>
<p>Performance financeira agrupada por modalidade de plano.</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">Valor Total</span>
<strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">Contratos</span>
<strong class="kpi-val">{{ formatMoney(contratosTotals?.valorTotal) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid planos-charts" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Top Planos (Valor)</h3>
<p>Os planos com maior representatividade financeira.</p>
</div>
<div class="chart-area">
<canvas #chartPlanos></canvas>
</div>
</div>
<div class="chart-card">
<div class="card-header-clean">
<h3>Top Planos (Volume)</h3>
<p>Quantidade de linhas ativas por tipo de plano.</p>
</div>
<div class="chart-area">
<canvas #chartPlanosLinhas></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Macrophony - Planos</h4>
<span>Detalhamento granular dos planos e suas variações.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<div class="macrophony-block" [class.compact]="macrophonyCompact">
<div class="table-tools">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[(ngModel)]="macrophonySearch"
(ngModelChange)="onMacrophonySearch()" />
</div>
<div class="tools-right">
<label class="select-label">
Exibir
<select [(ngModel)]="macrophonyPageSize" (ngModelChange)="onMacrophonyPageSizeChange()">
<option *ngFor="let size of macrophonyPageOptions" [value]="size">{{ size }}</option>
</select>
</label>
<div class="divider-v"></div>
<button class="btn-icon-text" type="button" (click)="toggleMacrophonyCompact()">
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()">
<i class="bi bi-download"></i>
<span class="hide-mobile">CSV</span>
</button>
</div>
</div>
<div class="macrophony-list">
<div class="empty-state" *ngIf="!loading && macrophonyView.length === 0">
<i class="bi bi-inbox"></i>
<p>Nenhum dado encontrado.</p>
</div>
<div class="macrophony-group" *ngFor="let group of macrophonyView; trackBy: trackByIndex">
<div class="macrophony-row" (click)="toggleMacrophonyGroup(group.key)">
<div class="row-trigger">
<button class="group-toggle" type="button">
<i class="bi" [class.bi-chevron-down]="isMacrophonyOpen(group.key)" [class.bi-chevron-right]="!isMacrophonyOpen(group.key)"></i>
</button>
</div>
<div class="group-main">
<div class="group-title">{{ group.plano }}</div>
<div class="group-meta">
<span class="badge-tag">GB {{ group.gbLabel }}</span>
<span class="badge-tag secondary">{{ formatNumber(group.totalLinhas) }} linhas</span>
</div>
</div>
<div class="group-metrics">
<div class="metric">
<span class="lbl">Valor Total</span>
<strong class="val-money">{{ formatMoney(group.valorTotal) }}</strong>
</div>
<div class="metric hide-mobile">
<span class="lbl">Média Un.</span>
<strong>{{ formatMoney(group.valorUnitMedio) }}</strong>
</div>
</div>
<div class="group-actions">
<button class="btn-mini" type="button" (click)="openMacrophonyDetail(group); $event.stopPropagation()">
Detalhes
</button>
</div>
</div>
<div class="macrophony-details" *ngIf="isMacrophonyOpen(group.key)">
<div class="table-wrap is-nested">
<table class="data-table" [class.compact]="macrophonyCompact">
<thead>
<tr>
<th>Plano / Variação</th>
<th>Franquia</th>
<th class="text-right">Valor Un.</th>
<th class="text-right">Linhas</th>
<th class="text-right">Total</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of group.rows; trackBy: trackByIndex">
<td>
<div class="cell-flex">
{{ row.planoContrato || '-' }}
<i *ngIf="isVivoTravel(row.vivoTravel)" class="bi bi-airplane-fill text-brand" title="Vivo Travel"></i>
</div>
</td>
<td>{{ formatGb(row.gb) }}</td>
<td class="text-right num-font">{{ formatMoney(row.valorIndividualComSvas) }}</td>
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
<td class="text-right num-font font-bold">{{ formatMoney(row.valorTotal) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="table-footer">
<div class="table-count">
Exibindo {{ macrophonyPageStart }}-{{ macrophonyPageEnd }} de {{ macrophonyFilteredGroups.length }} grupos
</div>
<div class="pagination">
<button class="page-btn" (click)="goToMacrophonyPage(macrophonyPage - 1)" [disabled]="macrophonyPage === 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="page-btn"
*ngFor="let p of macrophonyPageNumbers"
[class.active]="p === macrophonyPage"
(click)="goToMacrophonyPage(p)">{{ p }}</button>
<button class="page-btn" (click)="goToMacrophonyPage(macrophonyPage + 1)" [disabled]="macrophonyPage === macrophonyTotalPages">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="macrophony-summary" *ngIf="planosTotals">
<div class="summary-item">
<span>Total de Linhas</span>
<strong>{{ formatNumber(planosTotals.totalLinhasTotal) }}</strong>
</div>
<div class="summary-item highlight">
<span>Valor Total Global</span>
<strong>{{ formatMoney(planosTotals.valorTotal) }}</strong>
</div>
</div>
</div>
</details>
<details class="section-card">
<summary>
<div class="summary-content">
<h4>Resumo de Contratos</h4>
<span>Visão consolidada por tipo de contrato vigente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupPlanoContrato, footer: 'contratos', file: 'plano-contrato' }"></ng-container>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'clientes'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Clientes & Performance</h3>
<p>Analise a rentabilidade e custos por cliente.</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">Receita Line</span>
<strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong>
</div>
<div class="kpi-card highlight">
<span class="kpi-lbl">Lucro Total</span>
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Top Clientes (Lucratividade)</h3>
<p>Clientes ordenados pelo maior retorno financeiro.</p>
</div>
<div class="chart-area">
<canvas #chartClientes></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento Vivo x Line Móvel</h4>
<span>Comparativo de custos, receitas e margem por cliente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupClientes, footer: 'clientes', file: 'clientes-vivo-line' }"></ng-container>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'totais'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Totais Line</h3>
<p>Consolidado entre Pessoa Física (PF) e Jurídica (PJ).</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">PF Linhas</span>
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PF','PESSOA FISICA'])?.qtdLinhas) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">PJ Linhas</span>
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">Lucro Consolidado</span>
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Distribuição PF vs PJ</h3>
<p>Proporção da base de linhas ativas.</p>
</div>
<div class="chart-area">
<canvas #chartTotais></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento Totais</h4>
<span>Tabela analítica dos totais processados.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupTotaisLine, footer: 'none', file: 'totais-line' }"></ng-container>
</details>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Distribuição por GB</h4>
<span>Tabela GB / QTD / SOMA importada da aba RESUMO.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<div class="grouped-block">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>GB</th>
<th class="text-right">QTD</th>
<th class="text-right">SOMA</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of gbDistribuicaoRows; trackBy: trackByIndex">
<td><span class="num-font">{{ formatGb(row.gb) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatNumber(row.qtd) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatMoney(row.soma) }}</span></td>
</tr>
<tr class="total-row" *ngIf="gbDistribuicaoRows.length">
<td>Total</td>
<td class="text-right"><span class="num-font">{{ formatNumber(gbDistribuicaoTotalLinhas) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatMoney(gbDistribuicaoSomaTotal) }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'reserva'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Estoque de Reserva</h3>
<p>Monitoramento de linhas disponíveis por DDD.</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">Linhas em Estoque</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.qtdLinhasTotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">Custo de Reserva</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.total) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Concentração por DDD</h3>
<p>Regiões com maior volume de linhas em reserva.</p>
</div>
<div class="chart-area">
<canvas #chartReserva></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento por DDD</h4>
<span>Lista completa de estoque agrupada geograficamente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupReserva, footer: 'reserva', file: 'reserva-ddd' }"></ng-container>
</details>
</div>
</div>
</div>
<ng-template #groupedTableBlock let-group="group" let-footer="footer" let-file="file">
<div class="grouped-block" [class.compact]="group.compact">
<div class="table-tools">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[(ngModel)]="group.search"
(ngModelChange)="onGroupedSearch(group)" />
</div>
<div class="tools-right">
<button class="btn-icon-text" type="button" (click)="toggleGroupedCompact(group)">
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)">
<i class="bi bi-download"></i>
<span class="hide-mobile">CSV</span>
</button>
</div>
</div>
<div class="grouped-list">
<div class="empty-state" *ngIf="!loading && group.view.length === 0">
<p>Nenhum registro encontrado.</p>
</div>
<div class="grouped-group" *ngFor="let item of group.view; trackBy: trackByIndex">
<div class="grouped-row" (click)="toggleGroupedOpen(group, item.key)">
<div class="row-trigger">
<button class="group-toggle" type="button">
<i class="bi" [class.bi-chevron-down]="isGroupedOpen(group, item.key)" [class.bi-chevron-right]="!isGroupedOpen(group, item.key)"></i>
</button>
</div>
<div class="group-main">
<div class="group-title">{{ item.title }}</div>
<div class="group-subtitle" *ngIf="item.subtitle">{{ item.subtitle }}</div>
</div>
<div class="group-metrics">
<div class="metric" *ngFor="let metric of item.metrics">
<span class="lbl">{{ metric.label }}</span>
<strong class="num-font" [ngClass]="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<div class="group-actions">
<button class="btn-mini" type="button" (click)="openGroupedDetail(group, item); $event.stopPropagation()">
Ver Tabela
</button>
</div>
</div>
<div class="grouped-details" *ngIf="isGroupedOpen(group, item.key)">
<div class="table-wrap is-nested">
<table class="data-table" [class.compact]="group.compact">
<thead>
<tr>
<th
*ngFor="let col of group.table.columns"
[class.text-right]="col.align === 'right'"
[class.text-center]="col.align === 'center'">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of item.rows; trackBy: trackByIndex" [class.diff-row]="getTableRowClass(group.table, row)">
<td
*ngFor="let col of group.table.columns"
[class.text-right]="col.align === 'right'"
[class.text-center]="col.align === 'center'">
<span class="num-font" *ngIf="!col.badge" [ngClass]="col.tone ? getToneClass(col.value(row)) : null">
{{ formatCell(col, row) }}
</span>
<span *ngIf="col.badge" class="badge-tag">{{ formatCell(col, row) }}</span>
<i *ngIf="col.icon && col.icon(row)" [class]="col.icon(row)"></i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="grouped-summary-bar" *ngIf="footer === 'clientes' && clientesTotals">
<div class="sum-col">
<span class="lbl">Receita Line</span>
<strong>{{ formatMoney(clientesTotals.valorContratoLine) }}</strong>
</div>
<div class="sum-col">
<span class="lbl">Custo Vivo</span>
<strong>{{ formatMoney(clientesTotals.valorContratoVivo) }}</strong>
</div>
<div class="sum-col highlight">
<span class="lbl">Lucro Líquido</span>
<strong class="text-success">{{ formatMoney(clientesTotals.lucro) }}</strong>
</div>
</div>
<div class="table-footer">
<div class="table-count">Mostrando {{ getGroupedPageStart(group) }}-{{ getGroupedPageEnd(group) }} de {{ group.filtered.length }}</div>
<div class="pagination">
<button class="page-btn" (click)="goToGroupedPage(group, group.page - 1)" [disabled]="group.page === 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="page-btn"
*ngFor="let p of getGroupedPageNumbers(group)"
[class.active]="p === group.page"
(click)="goToGroupedPage(group, p)">{{ p }}</button>
<button class="page-btn" (click)="goToGroupedPage(group, group.page + 1)" [disabled]="group.page === getGroupedTotalPages(group)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
<div class="grouped-backdrop" *ngIf="group.detailOpen" (click)="closeGroupedDetail(group)"></div>
<div class="grouped-modal" *ngIf="group.detailOpen">
<div class="grouped-card" (click)="$event.stopPropagation()">
<div class="detail-head">
<h4>{{ group.detailGroup?.title }}</h4>
<button class="btn-icon" type="button" (click)="closeGroupedDetail(group)"><i class="bi bi-x-lg"></i></button>
</div>
<div class="grouped-modal-body">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th *ngFor="let col of group.table.columns" [class.text-right]="col.align === 'right'">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of group.detailGroup?.rows; trackBy: trackByIndex">
<td *ngFor="let col of group.table.columns" [class.text-right]="col.align === 'right'">
<span class="num-font">{{ formatCell(col, row) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</ng-template>
<div class="macrophony-backdrop" *ngIf="macrophonyDetailOpen" (click)="closeMacrophonyDetail()"></div>
<div class="macrophony-modal" *ngIf="macrophonyDetailOpen">
<div class="macrophony-card" (click)="$event.stopPropagation()">
<div class="detail-head">
<div>
<span class="detail-super">Detalhes do Plano</span>
<h4>{{ macrophonyDetailGroup?.plano }}</h4>
</div>
<button class="btn-icon" type="button" (click)="closeMacrophonyDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="macrophony-modal-body">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Variação</th>
<th>GB</th>
<th class="text-right">Valor Un.</th>
<th class="text-right">Total Linhas</th>
<th class="text-right">Valor Total</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of macrophonyDetailGroup?.rows; trackBy: trackByIndex">
<td>{{ row.planoContrato || '-' }}</td>
<td>{{ formatGb(row.gb) }}</td>
<td class="text-right num-font">{{ formatMoney(row.valorIndividualComSvas) }}</td>
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
<td class="text-right num-font">{{ formatMoney(row.valorTotal) }}</td>
</tr>
</tbody>
<tfoot *ngIf="macrophonyDetailGroup">
<tr class="total-row">
<td colspan="3">Total deste grupo</td>
<td class="text-right num-font">{{ formatNumber(macrophonyDetailGroup.totalLinhas) }}</td>
<td class="text-right num-font">{{ formatMoney(macrophonyDetailGroup.valorTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,790 @@
:host {
/* Tokens de Cor - Enterprise Palette */
--brand: #e33dcf;
--brand-hover: #c92bb6;
--brand-soft: rgba(227, 61, 207, 0.08);
--brand-gradient: linear-gradient(135deg, #e33dcf 0%, #b0249d 100%);
--blue: #030faa;
--blue-soft: rgba(3, 15, 170, 0.06);
--text-main: #0f172a;
--text-sec: #64748b;
--text-light: #94a3b8;
--border: #e2e8f0;
--surface: #ffffff;
--bg-page: #f8fafc;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
/* Elevation & Depth */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02);
--shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-elevated: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
--shadow-modal: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.2);
display: block;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-main);
background: var(--bg-page);
}
.resumo-page {
min-height: 100vh;
padding-bottom: 80px;
}
.wrap { padding-top: 32px; }
.resumo-container {
width: 100%;
max-width: 1360px; /* Ligeiramente mais largo para dashboard */
margin: 0 auto;
padding: 0 24px;
@media (max-width: 768px) { padding: 0 16px; }
}
/* Animações */
[data-animate] {
opacity: 1;
transform: translateY(0);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
:host(.animate-ready) [data-animate] {
opacity: 0;
transform: translateY(15px);
}
:host(.animate-ready) [data-animate].is-visible {
opacity: 1;
transform: translateY(0);
}
/* Header */
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
gap: 16px;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
.title-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
background: white;
border: 1px solid var(--border);
color: var(--brand);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: var(--shadow-sm);
width: fit-content;
}
.flex-title {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
margin: 0;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-main);
}
.page-subtitle {
margin: 0;
font-size: 14px;
color: var(--text-sec);
max-width: 600px;
}
/* Status Indicators */
.status-wrapper {
display: flex;
gap: 12px;
}
.status {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
&.loading { background: #eff6ff; color: var(--blue); }
&.error { background: #fef2f2; color: var(--danger); }
&.success { background: #f0fdf4; color: var(--success); }
}
.spin { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Botões */
.btn-ghost {
border: 1px solid var(--border);
background: white;
color: var(--text-main);
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
&:hover:not(:disabled) {
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
box-shadow: var(--shadow-card);
}
&:disabled { opacity: 0.6; cursor: wait; }
}
.btn-icon-text {
@extend .btn-ghost;
padding: 6px 12px;
font-size: 12px;
}
.btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(0,0,0,0.03);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(0,0,0,0.06);
color: var(--brand);
}
}
.btn-mini {
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
border: 1px solid var(--border);
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-sec);
&:hover {
border-color: var(--brand);
color: var(--brand);
}
}
/* Tabs */
.tab-bar {
display: flex;
gap: 6px;
padding: 4px;
border-radius: var(--radius-md);
background: white;
border: 1px solid var(--border);
margin-bottom: 24px;
width: fit-content;
box-shadow: var(--shadow-sm);
overflow-x: auto;
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
color: var(--text-sec);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover { color: var(--text-main); background: #f1f5f9; }
&.active {
background: var(--brand-soft);
color: var(--brand);
font-weight: 700;
}
}
/* Hero Section */
.section-hero {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-card);
}
.hero-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
@media (max-width: 900px) {
flex-direction: column;
align-items: flex-start;
}
}
.hero-text h3 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 700;
}
.hero-text p {
margin: 0;
color: var(--text-sec);
font-size: 13px;
}
.hero-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
width: 100%;
max-width: 600px;
}
.kpi-card {
background: #f8fafc;
border: 1px solid var(--border);
padding: 12px 16px;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 4px;
transition: transform 0.2s;
&:hover {
border-color: #cbd5e1;
background: white;
box-shadow: var(--shadow-sm);
}
&.highlight {
background: linear-gradient(to bottom right, #f8fafc, #fff);
border-left: 3px solid var(--success);
}
}
.kpi-lbl {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-sec);
letter-spacing: 0.04em;
}
.kpi-val {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
font-feature-settings: "tnum";
}
/* Grids */
.section-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.planos-charts .chart-card {
grid-column: span 6;
@media (max-width: 960px) { grid-column: span 12; }
}
.full-chart .chart-card {
grid-column: span 12;
}
.chart-card {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
height: 100%;
}
.chart-area {
position: relative;
height: 300px;
width: 100%;
margin-top: 16px;
}
.card-header-clean h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.card-header-clean p {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-sec);
}
/* Details/Summary Sections */
.section-card {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
margin-bottom: 24px;
transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-elevated); }
}
summary {
list-style: none;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
background: white;
border-bottom: 1px solid transparent;
transition: background 0.2s;
&:hover { background: #fcfcfc; }
&::-webkit-details-marker { display: none; }
}
details[open] summary {
border-bottom-color: var(--border);
background: #f8fafc;
}
.summary-content h4 {
margin: 0 0 2px;
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.summary-content span {
font-size: 13px;
color: var(--text-sec);
}
.summary-icon {
color: var(--text-sec);
transition: transform 0.3s ease;
}
details[open] .summary-icon { transform: rotate(180deg); }
/* Tabelas e Listas */
.table-tools {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
background: white;
}
.search-box {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
padding: 8px 12px;
border-radius: 8px;
width: 300px;
max-width: 100%;
border: 1px solid transparent;
transition: all 0.2s;
&:focus-within {
background: white;
border-color: var(--brand);
box-shadow: 0 0 0 2px var(--brand-soft);
}
input {
border: none;
background: transparent;
width: 100%;
outline: none;
font-size: 13px;
}
i { color: var(--text-sec); }
}
.tools-right {
display: flex;
align-items: center;
gap: 12px;
}
.select-label {
font-size: 12px;
font-weight: 600;
color: var(--text-sec);
display: flex;
align-items: center;
gap: 8px;
select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 24px 4px 8px;
font-size: 12px;
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 6px center / 10px;
appearance: none;
cursor: pointer;
&:hover { border-color: var(--text-light); }
&:focus { outline: none; border-color: var(--brand); }
}
}
.divider-v {
width: 1px;
height: 24px;
background: var(--border);
}
/* Listas Agrupadas */
.macrophony-row, .grouped-row {
display: grid;
grid-template-columns: 40px minmax(200px, 1.5fr) 2fr auto;
gap: 16px;
padding: 16px 24px;
align-items: center;
border-bottom: 1px solid var(--border);
background: white;
transition: background 0.1s;
cursor: pointer;
&:hover { background: #f8fafc; }
@media (max-width: 768px) {
grid-template-columns: 40px 1fr;
gap: 8px;
}
}
.group-toggle {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
background: white;
color: var(--text-sec);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover { border-color: var(--brand); color: var(--brand); }
}
.group-title {
font-weight: 700;
font-size: 14px;
color: var(--text-main);
}
.group-meta {
display: flex;
gap: 6px;
margin-top: 4px;
}
.group-metrics {
display: flex;
justify-content: flex-end;
gap: 24px;
@media (max-width: 768px) {
grid-column: 2;
justify-content: flex-start;
margin-top: 8px;
}
}
.metric {
display: flex;
flex-direction: column;
align-items: flex-end;
@media (max-width: 768px) { align-items: flex-start; }
}
.metric .lbl {
font-size: 10px;
text-transform: uppercase;
font-weight: 700;
color: var(--text-light);
letter-spacing: 0.05em;
}
.metric strong {
font-size: 14px;
color: var(--text-main);
}
.badge-tag {
background: #f1f5f9;
color: var(--text-main);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
&.secondary { background: white; border-color: var(--border); color: var(--text-sec); }
}
/* Tabelas Internas */
.macrophony-details, .grouped-details {
background: #f8fafc;
border-bottom: 1px solid var(--border);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.table-wrap {
overflow-x: auto;
&.is-nested { padding: 16px 24px; }
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
&.compact td, &.compact th { padding: 8px 12px; }
th {
text-align: center;
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
color: var(--text-sec);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(248, 250, 252, 0.95);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
td {
text-align: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-main);
transition: background 0.1s;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: white; }
/* Auxiliares */
.text-right { text-align: center; }
.text-center { text-align: center; }
.num-font { font-family: 'Roboto Mono', monospace; font-size: 12px; }
.font-bold { font-weight: 700; }
.text-brand { color: var(--brand); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
}
.diff-row td { background: #fff5f9; }
/* Footers */
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: white;
border-top: 1px solid var(--border);
@media (max-width: 600px) {
flex-direction: column;
gap: 12px;
}
}
.table-count {
font-size: 12px;
color: var(--text-sec);
}
.pagination {
display: flex;
gap: 4px;
}
.page-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid var(--border);
background: white;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
&:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
&.active { background: var(--brand); color: white; border-color: var(--brand); }
&:disabled { opacity: 0.5; cursor: default; }
}
/* Resumo Bars (Totais) */
.macrophony-summary, .grouped-summary-bar {
display: flex;
gap: 24px;
padding: 16px 24px;
background: #f8fafc;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.summary-item, .sum-col {
display: flex;
flex-direction: column;
span { font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; }
strong { font-size: 16px; color: var(--text-main); }
&.highlight strong { color: var(--brand); }
}
/* Modais */
.macrophony-modal, .grouped-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.macrophony-backdrop, .grouped-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
z-index: 90;
}
.macrophony-card, .grouped-card {
background: white;
width: 100%;
max-width: 800px;
max-height: 85vh;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
z-index: 101;
overflow: hidden;
animation: modalUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalUp {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.detail-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
background: white;
h4 { margin: 0; font-size: 18px; font-weight: 700; }
.detail-super { display: block; font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; margin-bottom: 4px; }
}
.macrophony-modal-body, .grouped-modal-body {
overflow-y: auto;
padding: 0;
}
/* Utils */
.hide-mobile {
@media (max-width: 600px) { display: none; }
}
.empty-state {
padding: 40px;
text-align: center;
color: var(--text-sec);
i { font-size: 32px; margin-bottom: 12px; display: block; opacity: 0.5; }
p { margin: 0; font-size: 14px; }
}

File diff suppressed because it is too large Load Diff

View File

@ -76,7 +76,7 @@
<span class="input-group-text"> <span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i> <i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
</span> </span>
<input class="form-control" placeholder="Pesquisar (linha, ICCID, motivo, observação)..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" /> <input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm"> <button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>

View File

@ -250,7 +250,7 @@
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.search-group { .search-group {
max-width: 380px; max-width: 270px;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;

View File

@ -23,7 +23,11 @@
<h5 class="title">GESTÃO DE VIGÊNCIA</h5> <h5 class="title">GESTÃO DE VIGÊNCIA</h5>
<small class="subtitle">Controle de contratos e fidelização</small> <small class="subtitle">Controle de contratos e fidelização</small>
</div> </div>
<div class="d-flex gap-2 justify-content-end"></div> <div class="header-actions d-flex gap-2 justify-content-end">
<button *ngIf="isAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button>
</div>
</div> </div>
<div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'"> <div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'">
@ -39,19 +43,25 @@
<span class="lbl text-danger">Total Vencidos</span> <span class="lbl text-danger">Total Vencidos</span>
<span class="val text-danger">{{ kpiTotalVencidos }}</span> <span class="val text-danger">{{ kpiTotalVencidos }}</span>
</div> </div>
<div class="kpi kpi-stack">
<span class="lbl text-brand">Valor Total</span>
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
</div>
</div> </div>
<div class="controls mt-3 mb-2 d-flex flex-wrap gap-3 align-items-center justify-content-between"> <div class="controls mt-3 mb-2 d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div class="search-group flex-grow-1" style="max-width: 400px;"> <div class="input-group input-group-sm search-group">
<div class="position-relative"> <span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" style="position: absolute; left: 14px; top: 10px; color: var(--muted);"></i> <i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
<input class="form-control ps-5" placeholder="Pesquisar cliente..." [(ngModel)]="search" (keyup.enter)="fetch(1)" [disabled]="loading"> </span>
<button *ngIf="search" class="btn btn-link position-absolute end-0 top-0 text-muted" (click)="clearFilters()"><i class="bi bi-x-circle"></i></button> <input
</div> class="form-control"
placeholder="Pesquisar..."
[(ngModel)]="search"
(ngModelChange)="onSearchChange()">
<button
class="btn btn-outline-secondary btn-clear"
type="button"
*ngIf="search"
(click)="clearFilters()">
<i class="bi bi-x-lg"></i>
</button>
</div> </div>
<div class="page-size d-flex align-items-center gap-2"> <div class="page-size d-flex align-items-center gap-2">
@ -109,7 +119,7 @@
<th>EFETIVAÇÃO</th> <th>EFETIVAÇÃO</th>
<th>VENCIMENTO</th> <th>VENCIMENTO</th>
<th class="text-end">TOTAL</th> <th class="text-end">TOTAL</th>
<th style="min-width: 80px;">AÇÕES</th> <th class="actions-col">AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -135,6 +145,8 @@
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -164,52 +176,258 @@
</div> </div>
</section> </section>
<div class="lg-backdrop" *ngIf="detailsOpen" (click)="closeDetails()"></div> <div class="lg-backdrop" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="lg-modal" *ngIf="detailsOpen"> <div class="lg-modal" *ngIf="detailsOpen">
<div class="lg-modal-card"> <div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header d-flex justify-content-between align-items-center p-3 border-bottom"> <div class="modal-header">
<h6 class="mb-0 fw-bold"><i class="bi bi-card-list me-2 text-brand"></i> Detalhes da Linha</h6> <div class="modal-title">
<button class="btn-close" (click)="closeDetails()"></button> <span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
Detalhes da Vigência
</div> </div>
<div class="modal-body p-4 bg-light-gray"> <button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
<div class="form-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Cliente</small>
<span class="fw-bold text-dark">{{ selectedRow?.cliente }}</span>
</div> </div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Linha</small> <div class="modal-body bg-light-gray">
<span class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</span> <div class="details-dashboard">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Linha</span>
</div> </div>
<div class="d-flex flex-column"> <div class="box-body">
<small class="text-muted fw-bold text-uppercase">Conta</small> <div class="info-grid">
<span>{{ selectedRow?.conta || '-' }}</span> <div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
</div> </div>
<div class="d-flex flex-column"> <div class="info-item">
<small class="text-muted fw-bold text-uppercase">Usuário</small> <span class="lbl">Linha</span>
<span>{{ selectedRow?.usuario || '-' }}</span> <span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
</div> </div>
<div class="d-flex flex-column span-2" style="grid-column: span 2;"> <div class="info-item">
<small class="text-muted fw-bold text-uppercase">Plano</small> <span class="lbl">Conta</span>
<span class="p-2 bg-white border rounded">{{ selectedRow?.planoContrato || '-' }}</span> <span class="val">{{ selectedRow?.conta || '-' }}</span>
</div> </div>
<div class="d-flex flex-column"> <div class="info-item span-2">
<small class="text-muted fw-bold text-uppercase">Efetivação</small> <span class="lbl">Usuário</span>
<span>{{ selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy' }}</span> <span class="val">{{ selectedRow?.usuario || '-' }}</span>
</div> </div>
<div class="d-flex flex-column"> <div class="info-item span-2">
<small class="text-muted fw-bold text-uppercase">Término</small> <span class="lbl">Plano</span>
<span class="text-danger fw-bold">{{ selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy' }}</span> <span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
</div> </div>
<div class="d-flex flex-column span-2 text-end pt-2 border-top"> <div class="info-item">
<small class="text-muted fw-bold text-uppercase">Valor Total</small> <span class="lbl">Efetivação</span>
<span class="fw-black text-brand fs-4">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span> <span class="val">{{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Término</span>
<span class="val" [class.text-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Situação</span>
<span class="status-pill" [class.is-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
<div class="modal-footer p-3 text-end border-top"> <div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button> <button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div> </div>
</div> </div>
</div> </div>
<!-- CREATE MODAL -->
<div class="lg-modal" *ngIf="createOpen">
<div class="lg-modal-card modal-xl create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Nova Vigência
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com GERAL</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="clientsFromGeral"
[(ngModel)]="createModel.selectedClient"
(ngModelChange)="onCreateClientChange()"
[disabled]="createClientsLoading"
></app-select>
</div>
<div class="form-field span-2">
<label>Linha (GERAL)</label>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="createModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="createModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /></div>
<div class="form-field span-2">
<label>Plano</label>
<app-select
*ngIf="planOptions.length > 0"
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
></app-select>
<input
*ngIf="planOptions.length === 0"
class="form-control form-control-sm"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
/>
</div>
<div class="form-field"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.item" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- EDIT MODAL -->
<div class="lg-modal" *ngIf="editOpen">
<div class="lg-modal-card modal-xl" (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 Vigência
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
<div class="form-field span-2"><label>Plano</label><input class="form-control form-control-sm" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onEditPlanChange()" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- DELETE MODAL -->
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Vigência
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>

View File

@ -6,6 +6,9 @@
--blue: #030FAA; --blue: #030FAA;
--text: #111214; --text: #111214;
--muted: rgba(17, 18, 20, 0.65); --muted: rgba(17, 18, 20, 0.65);
--surface-soft: rgba(255, 255, 255, 0.7);
--surface-hover: rgba(255, 255, 255, 0.94);
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16);
--success-bg: rgba(25, 135, 84, 0.1); --success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754; --success-text: #198754;
@ -75,42 +78,107 @@
.title-badge { .title-badge {
display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px; display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px;
border-radius: 999px; background: rgba(255, 255, 255, 0.78); border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.7));
border: 1px solid rgba(227, 61, 207, 0.22); font-size: 13px; font-weight: 800; border: 1px solid rgba(227, 61, 207, 0.22); font-size: 13px; font-weight: 800;
box-shadow: 0 8px 20px rgba(17, 18, 20, 0.06);
i { color: var(--brand); } i { color: var(--brand); }
} }
.header-title { text-align: center; } .header-title { text-align: center; }
.title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; } .title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; }
.subtitle { color: var(--muted); font-weight: 700; } .subtitle { color: var(--muted); font-weight: 700; }
.header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
white-space: nowrap;
min-height: 38px;
border-width: 1px;
}
}
/* KPIs */ /* KPIs */
.mureg-kpis { .mureg-kpis {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; display: grid;
grid-template-columns: repeat(3, minmax(158px, 205px));
justify-content: center;
gap: 8px;
.kpi { .kpi {
background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08);
border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; border-radius: 14px;
padding: 8px 10px;
min-height: 58px;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.2s; transition: transform 0.2s;
&:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; }
.lbl { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; color: var(--muted); } .lbl { font-size: 0.64rem; font-weight: 900; text-transform: uppercase; color: var(--muted); }
.val { font-size: 1.25rem; font-weight: 950; color: var(--text); } .val { font-size: 1.02rem; font-weight: 950; color: var(--text); }
.text-brand { color: var(--brand) !important; } .text-brand { color: var(--brand) !important; }
} }
.kpi.kpi-stack {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
}
} }
/* Controls */ /* Controls */
.search-group { .search-group {
border-radius: 12px; background: #fff; border: 1px solid rgba(17,18,20,0.15); display: flex; align-items: center; max-width: 270px;
&:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); } border-radius: 12px;
.form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; &:focus { outline: none; } } overflow: hidden;
display: flex;
align-items: stretch;
background: #fff;
border: 1px solid rgba(17,18,20,0.15);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
&:focus-within {
border-color: var(--brand);
box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15);
transform: translateY(-1px);
}
.input-group-text {
background: transparent;
border: none;
color: var(--muted);
padding-left: 14px;
padding-right: 8px;
display: flex;
align-items: center;
}
.form-control {
border: none;
background: transparent;
padding: 10px 0;
font-size: 0.9rem;
color: var(--text);
box-shadow: none;
&::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; }
&:focus { outline: none; }
}
.btn-clear {
background: transparent;
border: none;
color: var(--muted);
padding: 0 12px;
display: flex;
align-items: center;
cursor: pointer;
transition: color 0.2s;
&:hover { color: #dc3545; }
}
} }
.select-glass { .select-glass {
@ -118,6 +186,75 @@
color: var(--blue); font-weight: 800; color: var(--blue); font-weight: 800;
} }
.btn-brand,
.btn-glass,
.btn-primary,
.btn-danger {
border-radius: 12px;
font-weight: 900;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s, filter 0.2s;
&:hover {
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
&:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
box-shadow: 0 10px 22px rgba(227, 61, 207, 0.22);
&:hover {
box-shadow: 0 12px 24px rgba(227, 61, 207, 0.28);
filter: brightness(1.05);
}
}
.btn-glass {
background: var(--surface-soft);
border: 1px solid rgba(3, 15, 170, 0.24);
color: var(--blue);
box-shadow: 0 6px 16px rgba(3, 15, 170, 0.1);
&:hover {
background: var(--surface-hover);
border-color: var(--brand);
color: var(--brand);
box-shadow: 0 8px 18px rgba(227, 61, 207, 0.16);
}
}
.btn-primary {
background: linear-gradient(135deg, #1543ff, #030faa);
border-color: #030faa;
color: #fff;
box-shadow: 0 10px 22px rgba(3, 15, 170, 0.28);
&:hover {
box-shadow: 0 12px 24px rgba(3, 15, 170, 0.3);
filter: brightness(1.05);
}
}
.btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: #dc2626;
box-shadow: 0 10px 22px rgba(220, 38, 38, 0.26);
}
/* BODY E GRUPOS */ /* BODY E GRUPOS */
.geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.groups-container { padding: 16px; overflow-y: auto; height: 100%; } .groups-container { padding: 16px; overflow-y: auto; height: 100%; }
@ -166,10 +303,23 @@
.text-blue { color: var(--blue) !important; } .text-blue { color: var(--blue) !important; }
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions-col { min-width: 152px; }
.action-group {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.btn-icon { .btn-icon {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; color: rgba(17,18,20,0.5); width: 32px; height: 32px; border: none; background: rgba(17,18,20,0.04); border-radius: 8px; color: rgba(17,18,20,0.6);
display: flex; align-items: center; justify-content: center; transition: all 0.2s; display: flex; align-items: center; justify-content: center; transition: all 0.2s;
&:hover { background: rgba(3,15,170,0.1); color: var(--blue); } &:hover { background: rgba(17,18,20,0.08); color: var(--text); transform: translateY(-1px); }
&.primary:hover { background: rgba(3,15,170,0.1); color: var(--blue); }
&.danger:hover { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
} }
/* FOOTER */ /* FOOTER */
@ -177,10 +327,309 @@
padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center;
} }
.pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: #fff; margin: 0 2px; } .pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: #fff; margin: 0 2px; }
.pagination-modern .page-link:hover { border-color: var(--brand); color: var(--brand); background: rgba(255, 255, 255, 0.98); }
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; } .pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODAL */ /* MODAL */
.lg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } .lg-backdrop {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%);
z-index: 9990;
backdrop-filter: blur(5px);
}
.lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.lg-modal-card { background: #ffffff; border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); width: 600px; overflow: hidden; animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.86);
border-radius: 20px;
box-shadow: 0 30px 60px -18px rgba(0, 0, 0, 0.4);
width: min(860px, 96vw);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.lg-modal-card.modal-lg { width: min(760px, 94vw); }
.lg-modal-card.modal-xl { width: min(1040px, 95vw); max-height: 86vh; }
.lg-modal-card .modal-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(0,0,0,0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.09), rgba(255, 255, 255, 0.95) 70%);
display: flex;
justify-content: space-between;
align-items: center;
}
.lg-modal-card .modal-title {
font-size: 1.08rem;
font-weight: 900;
color: var(--text);
display: flex;
align-items: center;
gap: 12px;
}
.lg-modal-card .icon-bg {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
background: rgba(3, 15, 170, 0.1);
color: var(--blue);
}
.lg-modal-card .icon-bg.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
.lg-modal-card .icon-bg.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
.lg-modal-card .modal-body { flex: 1; min-height: 0; overflow-y: auto; }
.lg-modal-card .modal-footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96));
}
.lg-modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
.lg-modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
.lg-modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
.lg-modal-card.create-modal .edit-sections { gap: 14px; }
.lg-modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
.lg-modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
.lg-modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
.lg-modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
.lg-modal-card.create-modal .form-control,
.lg-modal-card.create-modal .form-select { min-height: 40px; }
.lg-modal-card.create-modal .modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 14px 20px !important;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
}
.lg-modal-card.create-modal .modal-footer .btn {
border-radius: 12px;
font-weight: 900;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.lg-modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
.bg-light-gray { background-color: #f8f9fa; }
.lg-modal-card .btn-icon {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(17, 18, 20, 0.04);
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(17, 18, 20, 0.08);
color: var(--brand);
transform: translateY(-1px);
}
}
@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 16px; }
.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.06); box-shadow: 0 2px 10px rgba(0,0,0,0.03); overflow: hidden; }
.box-header {
padding: 10px 16px;
font-size: 0.76rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(0,0,0,0.05);
background: #fdfdfd;
display: flex;
align-items: center;
}
.box-header.justify-content-center {
justify-content: center;
text-align: center;
color: var(--brand);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
}
.box-body { padding: 16px; }
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.info-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 8px;
background: rgba(245, 245, 247, 0.55);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.04);
&.span-2 { grid-column: span 2; }
.lbl {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--muted);
margin-bottom: 2px;
}
.val {
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
word-break: break-word;
line-height: 1.25;
}
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 84px;
padding: 5px 12px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.03em;
background: rgba(25, 135, 84, 0.12);
color: #157347;
border: 1px solid rgba(25, 135, 84, 0.24);
}
.status-pill.is-danger {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
border-color: rgba(220, 53, 69, 0.24);
}
.edit-sections { display: grid; gap: 12px; }
.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
summary.box-header {
cursor: pointer;
list-style: none;
user-select: none;
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
&::-webkit-details-marker { display: none; }
}
.transition-icon { color: var(--muted); transition: transform 0.25s ease, color 0.25s ease; }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
&.span-2 { grid-column: span 2; }
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.64);
}
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.15);
background: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.36); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}
@media (max-width: 992px) {
.mureg-kpis {
grid-template-columns: repeat(2, minmax(150px, 198px));
}
}
@media (max-width: 700px) {
.mureg-kpis {
grid-template-columns: minmax(0, 1fr);
justify-content: stretch;
}
.lg-modal-card { border-radius: 16px; }
.lg-modal-card .modal-header { padding: 12px 16px; }
.lg-modal-card .modal-body { padding: 16px !important; }
.lg-modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
.lg-modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
.form-grid,
.info-grid { grid-template-columns: 1fr; }
.info-item.span-2,
.form-field.span-2 { grid-column: span 1; }
}

View File

@ -1,14 +1,25 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service'; import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger'; type ToastType = 'success' | 'danger';
type ViewMode = 'lines' | 'groups'; type ViewMode = 'lines' | 'groups';
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
usuario: string | null;
label?: string;
}
@Component({ @Component({
selector: 'app-vigencia', selector: 'app-vigencia',
standalone: true, standalone: true,
@ -16,7 +27,7 @@ type ViewMode = 'lines' | 'groups';
templateUrl: './vigencia.html', templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'], styleUrls: ['./vigencia.scss'],
}) })
export class VigenciaComponent implements OnInit { export class VigenciaComponent implements OnInit, OnDestroy {
loading = false; loading = false;
errorMsg = ''; errorMsg = '';
@ -46,7 +57,6 @@ export class VigenciaComponent implements OnInit {
kpiTotalClientes = 0; kpiTotalClientes = 0;
kpiTotalLinhas = 0; kpiTotalLinhas = 0;
kpiTotalVencidos = 0; kpiTotalVencidos = 0;
kpiValorTotal = 0;
// === ACORDEÃO === // === ACORDEÃO ===
expandedGroup: string | null = null; expandedGroup: string | null = null;
@ -56,18 +66,63 @@ export class VigenciaComponent implements OnInit {
// UI // UI
detailsOpen = false; detailsOpen = false;
selectedRow: VigenciaRow | null = null; selectedRow: VigenciaRow | null = null;
editOpen = false;
editSaving = false;
editModel: VigenciaRow | null = null;
editEfetivacao = '';
editTermino = '';
editingId: string | null = null;
deleteOpen = false;
deleteTarget: VigenciaRow | null = null;
createOpen = false;
createSaving = false;
createModel: any = {
selectedClient: '',
mobileLineId: '',
item: '',
conta: '',
linha: '',
cliente: '',
usuario: '',
planoContrato: '',
total: null
};
createEfetivacao = '';
createTermino = '';
lineOptionsCreate: LineOptionDto[] = [];
createClientsLoading = false;
createLinesLoading = false;
clientsFromGeral: string[] = [];
planOptions: string[] = [];
isAdmin = false;
toastOpen = false; toastOpen = false;
toastMessage = ''; toastMessage = '';
toastType: ToastType = 'success'; toastType: ToastType = 'success';
private toastTimer: any = null; private toastTimer: any = null;
private searchTimer: any = null;
constructor(private vigenciaService: VigenciaService) {} constructor(
private vigenciaService: VigenciaService,
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService
) {}
ngOnInit(): void { ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.loadClients(); this.loadClients();
this.loadPlanRules();
this.fetch(1); this.fetch(1);
} }
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
}
setView(mode: ViewMode): void { setView(mode: ViewMode): void {
if (this.viewMode === mode) return; if (this.viewMode === mode) return;
this.viewMode = mode; this.viewMode = mode;
@ -85,6 +140,15 @@ export class VigenciaComponent implements OnInit {
}); });
} }
private async loadPlanRules() {
try {
await this.planAutoFill.load();
this.planOptions = this.planAutoFill.getPlanOptions();
} catch {
this.planOptions = [];
}
}
get totalPages(): number { get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
} }
@ -120,7 +184,6 @@ export class VigenciaComponent implements OnInit {
this.kpiTotalClientes = res.kpis.totalClientes; this.kpiTotalClientes = res.kpis.totalClientes;
this.kpiTotalLinhas = res.kpis.totalLinhas; this.kpiTotalLinhas = res.kpis.totalLinhas;
this.kpiTotalVencidos = res.kpis.totalVencidos; this.kpiTotalVencidos = res.kpis.totalVencidos;
this.kpiValorTotal = res.kpis.valorTotal;
this.loading = false; this.loading = false;
}, },
@ -199,10 +262,298 @@ export class VigenciaComponent implements OnInit {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()); return new Date(d.getFullYear(), d.getMonth(), d.getDate());
} }
clearFilters() { this.search = ''; this.fetch(1); } onSearchChange() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.fetch(1), 300);
}
clearFilters() {
this.search = '';
if (this.searchTimer) clearTimeout(this.searchTimer);
this.fetch(1);
}
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; } openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; } closeDetails() { this.detailsOpen = false; }
openEdit(r: VigenciaRow) {
if (!this.isAdmin) return;
this.editingId = r.id;
this.editModel = { ...r };
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
this.editTermino = this.toDateInput(r.dtTerminoFidelizacao);
this.editOpen = true;
}
closeEdit() {
this.editOpen = false;
this.editSaving = false;
this.editModel = null;
this.editEfetivacao = '';
this.editTermino = '';
this.editingId = null;
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const payload: UpdateVigenciaRequest = {
item: this.toNullableNumber(this.editModel.item),
conta: this.editModel.conta,
linha: this.editModel.linha,
cliente: this.editModel.cliente,
usuario: this.editModel.usuario,
planoContrato: this.editModel.planoContrato,
dtEfetivacaoServico: this.dateInputToIso(this.editEfetivacao),
dtTerminoFidelizacao: this.dateInputToIso(this.editTermino),
total: this.toNullableNumber(this.editModel.total)
};
this.vigenciaService.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.fetch();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.editSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
// ==========================
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
}
closeCreate() {
this.createOpen = false;
this.createSaving = false;
this.createModel = null;
}
private resetCreateModel() {
this.createModel = {
selectedClient: '',
mobileLineId: '',
item: '',
conta: '',
linha: '',
cliente: '',
usuario: '',
planoContrato: '',
total: null
};
this.createEfetivacao = '';
this.createTermino = '';
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.createClientsLoading = false;
}
private preloadGeralClients() {
this.createClientsLoading = true;
this.linesService.getClients().subscribe({
next: (list) => {
this.clientsFromGeral = list ?? [];
this.createClientsLoading = false;
},
error: () => {
this.clientsFromGeral = [];
this.createClientsLoading = false;
}
});
}
onCreateClientChange() {
const c = (this.createModel.selectedClient ?? '').trim();
this.createModel.mobileLineId = '';
this.createModel.linha = '';
this.createModel.conta = '';
this.createModel.usuario = '';
this.createModel.planoContrato = '';
this.createModel.total = null;
this.createModel.cliente = c;
this.lineOptionsCreate = [];
if (c) this.loadLinesForClient(c);
}
private loadLinesForClient(cliente: string) {
const c = (cliente ?? '').trim();
if (!c) return;
this.createLinesLoading = true;
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,
usuario: x.usuario ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());
this.lineOptionsCreate = mapped;
this.createLinesLoading = false;
},
error: () => {
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
}
});
}
onCreateLineChange() {
const id = String(this.createModel.mobileLineId ?? '').trim();
if (!id) return;
this.linesService.getById(id).subscribe({
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
});
}
private applyLineDetailToCreate(d: MobileLineDetail) {
this.createModel.linha = d.linha ?? '';
this.createModel.conta = d.conta ?? '';
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
this.createModel.usuario = d.usuario ?? '';
this.createModel.planoContrato = d.planoContrato ?? '';
this.createEfetivacao = this.toDateInput(d.dtEfetivacaoServico ?? null);
this.createTermino = this.toDateInput(d.dtTerminoFidelizacao ?? null);
this.ensurePlanOption(this.createModel.planoContrato);
if (!String(this.createModel.item ?? '').trim() && d.item) {
this.createModel.item = String(d.item);
}
this.onCreatePlanChange();
}
onCreatePlanChange() {
this.ensurePlanOption(this.createModel?.planoContrato);
this.applyPlanSuggestion(this.createModel);
}
onEditPlanChange() {
if (!this.editModel) return;
this.ensurePlanOption(this.editModel?.planoContrato);
this.applyPlanSuggestion(this.editModel);
}
private applyPlanSuggestion(model: any) {
const plan = (model?.planoContrato ?? '').toString().trim();
if (!plan) return;
const suggestion = this.planAutoFill.suggest(plan);
if (!suggestion) return;
if (suggestion.valorPlano != null) {
model.total = suggestion.valorPlano;
}
}
private ensurePlanOption(plan: any) {
const p = (plan ?? '').toString().trim();
if (!p) return;
if (!this.planOptions.includes(p)) {
this.planOptions = [p, ...this.planOptions];
}
}
saveCreate() {
if (!this.createModel) return;
this.applyPlanSuggestion(this.createModel);
const payload = {
item: this.toNullableNumber(this.createModel.item),
conta: this.createModel.conta,
linha: this.createModel.linha,
cliente: this.createModel.cliente,
usuario: this.createModel.usuario,
planoContrato: this.createModel.planoContrato,
dtEfetivacaoServico: this.dateInputToIso(this.createEfetivacao),
dtTerminoFidelizacao: this.dateInputToIso(this.createTermino),
total: this.toNullableNumber(this.createModel.total)
};
this.createSaving = true;
this.vigenciaService.create(payload).subscribe({
next: () => {
this.createSaving = false;
this.closeCreate();
this.fetch();
this.showToast('Vigência criada com sucesso!', 'success');
},
error: () => {
this.createSaving = false;
this.showToast('Erro ao criar vigência.', 'danger');
}
});
}
openDelete(r: VigenciaRow) {
if (!this.isAdmin) return;
this.deleteTarget = r;
this.deleteOpen = true;
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
}
confirmDelete() {
if (!this.deleteTarget) return;
const id = this.deleteTarget.id;
this.vigenciaService.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.fetch();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
handleError(err: HttpErrorResponse, msg: string) { handleError(err: HttpErrorResponse, msg: string) {
this.loading = false; this.loading = false;
this.expandedLoading = false; this.expandedLoading = false;

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { BehaviorSubject } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
export interface RegisterPayload { export interface RegisterPayload {
@ -16,35 +17,112 @@ export interface LoginPayload {
password: string; password: string;
} }
export interface LoginOptions {
rememberMe?: boolean;
}
export interface LoginResponse {
token?: string;
accessToken?: string;
}
export interface AuthUserProfile {
id: string;
nome: string;
email: string;
tenantId: string;
roles: string[];
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private baseUrl = `${environment.apiUrl}/auth`; private baseUrl = `${environment.apiUrl}/auth`;
private userProfileSubject = new BehaviorSubject<AuthUserProfile | null>(null);
readonly userProfile$ = this.userProfileSubject.asObservable();
private readonly tokenStorageKey = 'token';
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
private readonly rememberMeHours = 6;
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {
this.syncUserProfileFromToken();
}
register(payload: RegisterPayload) { register(payload: RegisterPayload) {
return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload) return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload)
.pipe(tap(r => localStorage.setItem('token', r.token))); .pipe(tap(r => this.setToken(r.token)));
} }
login(payload: LoginPayload) { login(payload: LoginPayload, options?: LoginOptions) {
return this.http.post<{ token: string }>(`${this.baseUrl}/login`, payload) return this.http.post<LoginResponse>(`${this.baseUrl}/login`, payload)
.pipe(tap(r => localStorage.setItem('token', r.token))); .pipe(
tap((r) => {
const token = this.resolveLoginToken(r);
if (!token) return;
this.setToken(token, options?.rememberMe ?? false);
})
);
} }
logout() { logout() {
localStorage.removeItem('token'); if (typeof window === 'undefined') {
this.userProfileSubject.next(null);
return;
}
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
this.userProfileSubject.next(null);
}
setToken(token: string, rememberMe = false) {
if (typeof window === 'undefined') return;
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
if (rememberMe) {
const expiresAt = Date.now() + this.rememberMeHours * 60 * 60 * 1000;
localStorage.setItem(this.tokenStorageKey, token);
localStorage.setItem(this.tokenExpiresAtKey, String(expiresAt));
} else {
sessionStorage.setItem(this.tokenStorageKey, token);
}
this.syncUserProfileFromToken();
} }
get token(): string | null { get token(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem('token'); this.cleanupExpiredRememberSession();
const sessionToken = sessionStorage.getItem(this.tokenStorageKey);
if (sessionToken) return sessionToken;
return localStorage.getItem(this.tokenStorageKey);
} }
isLoggedIn(): boolean { isLoggedIn(): boolean {
return !!this.token; return !!this.token;
} }
get currentUserProfile(): AuthUserProfile | null {
return this.userProfileSubject.value;
}
syncUserProfileFromToken() {
this.userProfileSubject.next(this.buildProfileFromToken());
}
updateUserProfile(profile: Pick<AuthUserProfile, 'nome' | 'email'>) {
const current = this.userProfileSubject.value;
if (!current) return;
this.userProfileSubject.next({
...current,
nome: profile.nome.trim(),
email: profile.email.trim().toLowerCase(),
});
}
getTokenPayload(): Record<string, any> | null { getTokenPayload(): Record<string, any> | null {
const token = this.token; const token = this.token;
if (!token) return null; if (!token) return null;
@ -66,6 +144,10 @@ export class AuthService {
getRoles(): string[] { getRoles(): string[] {
const payload = this.getTokenPayload(); const payload = this.getTokenPayload();
if (!payload) return []; if (!payload) return [];
return this.extractRoles(payload);
}
private extractRoles(payload: Record<string, any>): string[] {
const possibleKeys = [ const possibleKeys = [
'role', 'role',
'roles', 'roles',
@ -81,9 +163,74 @@ export class AuthService {
return roles.map(r => r.toLowerCase()); return roles.map(r => r.toLowerCase());
} }
private buildProfileFromToken(): AuthUserProfile | null {
const payload = this.getTokenPayload();
if (!payload) return null;
const id = String(
payload['sub'] ??
payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] ??
''
).trim();
const nome = String(payload['name'] ?? '').trim();
const email = String(
payload['email'] ??
payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] ??
''
).trim().toLowerCase();
const tenantId = String(
payload['tenantId'] ??
payload['tenant'] ??
payload['TenantId'] ??
''
).trim();
if (!id || !tenantId) return null;
return {
id,
nome,
email,
tenantId,
roles: this.extractRoles(payload),
};
}
hasRole(role: string): boolean { hasRole(role: string): boolean {
const target = (role || '').toLowerCase(); const target = (role || '').toLowerCase();
if (!target) return false; if (!target) return false;
return this.getRoles().includes(target); return this.getRoles().includes(target);
} }
private cleanupExpiredRememberSession() {
const token = localStorage.getItem(this.tokenStorageKey);
if (!token) return;
const expiresAtRaw = localStorage.getItem(this.tokenExpiresAtKey);
if (!expiresAtRaw) {
this.clearTokenStorage(localStorage);
return;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt)) {
this.clearTokenStorage(localStorage);
return;
}
if (Date.now() > expiresAt) {
this.clearTokenStorage(localStorage);
}
}
private clearTokenStorage(storage: Storage) {
storage.removeItem(this.tokenStorageKey);
storage.removeItem(this.tokenExpiresAtKey);
}
private resolveLoginToken(response: LoginResponse | null | undefined): string | null {
const raw = response?.token ?? response?.accessToken ?? null;
const token = (raw ?? '').toString().trim();
return token || null;
}
} }

View File

@ -34,6 +34,22 @@ export interface BillingItem {
aparelho?: string | null; aparelho?: string | null;
formaPagamento?: string | null; formaPagamento?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface BillingUpdateRequest {
tipo?: string;
item?: number | null;
cliente?: string | null;
qtdLinhas?: number | null;
franquiaVivo?: number | null;
valorContratoVivo?: number | null;
franquiaLine?: number | null;
valorContratoLine?: number | null;
lucro?: number | null;
aparelho?: string | null;
formaPagamento?: string | null;
} }
export interface BillingQuery { export interface BillingQuery {
@ -84,4 +100,16 @@ export class BillingService {
return this.getPaged(q).pipe(map((res) => res.items ?? [])); return this.getPaged(q).pipe(map((res) => res.items ?? []));
} }
getById(id: string): Observable<BillingItem> {
return this.http.get<BillingItem>(`${this.baseUrl}/${id}`);
}
update(id: string, payload: BillingUpdateRequest): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}`, payload);
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
} }

View File

@ -17,8 +17,18 @@ export interface ChipVirgemListDto {
item: number; item: number;
numeroDoChip: string | null; numeroDoChip: string | null;
observacoes: string | null; observacoes: string | null;
createdAt?: string | null;
updatedAt?: string | null;
} }
export interface UpdateChipVirgemRequest {
item?: number | null;
numeroDoChip?: string | null;
observacoes?: string | null;
}
export interface CreateChipVirgemRequest extends UpdateChipVirgemRequest {}
export interface ControleRecebidoListDto { export interface ControleRecebidoListDto {
id: string; id: string;
ano: number | null; ano: number | null;
@ -34,8 +44,28 @@ export interface ControleRecebidoListDto {
dataDoRecebimento: string | null; dataDoRecebimento: string | null;
quantidade: number | null; quantidade: number | null;
isResumo: boolean | null; isResumo: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
} }
export interface UpdateControleRecebidoRequest {
ano?: number | null;
item?: number | null;
notaFiscal?: string | null;
chip?: string | null;
serial?: string | null;
conteudoDaNf?: string | null;
numeroDaLinha?: string | null;
valorUnit?: number | null;
valorDaNf?: number | null;
dataDaNf?: string | null;
dataDoRecebimento?: string | null;
quantidade?: number | null;
isResumo?: boolean | null;
}
export interface CreateControleRecebidoRequest extends UpdateControleRecebidoRequest {}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChipsControleService { export class ChipsControleService {
private readonly baseApi: string; private readonly baseApi: string;
@ -67,6 +97,18 @@ export class ChipsControleService {
return this.http.get<ChipVirgemListDto>(`${this.baseApi}/chips-virgens/${id}`); return this.http.get<ChipVirgemListDto>(`${this.baseApi}/chips-virgens/${id}`);
} }
updateChipVirgem(id: string, payload: UpdateChipVirgemRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/chips-virgens/${id}`, payload);
}
createChipVirgem(payload: CreateChipVirgemRequest): Observable<ChipVirgemListDto> {
return this.http.post<ChipVirgemListDto>(`${this.baseApi}/chips-virgens`, payload);
}
removeChipVirgem(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/chips-virgens/${id}`);
}
getControleRecebidos(opts: { getControleRecebidos(opts: {
ano?: number | string | null; ano?: number | string | null;
isResumo?: boolean | string | null; isResumo?: boolean | string | null;
@ -95,4 +137,16 @@ export class ChipsControleService {
getControleRecebidoById(id: string): Observable<ControleRecebidoListDto> { getControleRecebidoById(id: string): Observable<ControleRecebidoListDto> {
return this.http.get<ControleRecebidoListDto>(`${this.baseApi}/controle-recebidos/${id}`); return this.http.get<ControleRecebidoListDto>(`${this.baseApi}/controle-recebidos/${id}`);
} }
updateControleRecebido(id: string, payload: UpdateControleRecebidoRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/controle-recebidos/${id}`, payload);
}
createControleRecebido(payload: CreateControleRecebidoRequest): Observable<ControleRecebidoListDto> {
return this.http.post<ControleRecebidoListDto>(`${this.baseApi}/controle-recebidos`, payload);
}
removeControleRecebido(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/controle-recebidos/${id}`);
}
} }

View File

@ -17,6 +17,10 @@ export interface UserDataRow {
item: number; item: number;
linha: string | null; linha: string | null;
cliente: string | null; cliente: string | null;
tipoPessoa?: string | null;
nome?: string | null;
razaoSocial?: string | null;
cnpj?: string | null;
cpf: string | null; cpf: string | null;
email: string | null; email: string | null;
celular: string | null; celular: string | null;
@ -26,10 +30,30 @@ export interface UserDataRow {
dataNascimento: string | null; dataNascimento: string | null;
} }
export interface UpdateUserDataRequest {
item?: number | null;
linha?: string | null;
cliente?: string | null;
tipoPessoa?: string | null;
nome?: string | null;
razaoSocial?: string | null;
cnpj?: string | null;
cpf?: string | null;
rg?: string | null;
dataNascimento?: string | null;
email?: string | null;
endereco?: string | null;
celular?: string | null;
telefoneFixo?: string | null;
}
export interface CreateUserDataRequest extends UpdateUserDataRequest {}
export interface UserDataClientGroup { export interface UserDataClientGroup {
cliente: string; cliente: string;
totalRegistros: number; totalRegistros: number;
comCpf: number; comCpf: number;
comCnpj: number;
comEmail: number; comEmail: number;
} }
@ -37,6 +61,7 @@ export interface UserDataKpis {
totalRegistros: number; totalRegistros: number;
clientesUnicos: number; clientesUnicos: number;
comCpf: number; comCpf: number;
comCnpj: number;
comEmail: number; comEmail: number;
} }
@ -56,6 +81,7 @@ export class DadosUsuariosService {
getGroups(opts: { getGroups(opts: {
search?: string; search?: string;
tipo?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@ -63,6 +89,7 @@ export class DadosUsuariosService {
}): Observable<UserDataGroupResponse> { }): Observable<UserDataGroupResponse> {
let params = new HttpParams(); let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search); if (opts.search) params = params.set('search', opts.search);
if (opts.tipo) params = params.set('tipo', opts.tipo);
params = params.set('page', String(opts.page || 1)); params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 10)); params = params.set('pageSize', String(opts.pageSize || 10));
@ -75,6 +102,7 @@ export class DadosUsuariosService {
getRows(opts: { getRows(opts: {
search?: string; search?: string;
client?: string; client?: string;
tipo?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@ -83,6 +111,7 @@ export class DadosUsuariosService {
let params = new HttpParams(); let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search); if (opts.search) params = params.set('search', opts.search);
if (opts.client) params = params.set('client', opts.client); if (opts.client) params = params.set('client', opts.client);
if (opts.tipo) params = params.set('tipo', opts.tipo);
params = params.set('page', String(opts.page || 1)); params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 20)); params = params.set('pageSize', String(opts.pageSize || 20));
@ -92,11 +121,25 @@ export class DadosUsuariosService {
return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params }); return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params });
} }
getClients(): Observable<string[]> { getClients(tipo?: string): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`); let params = new HttpParams();
if (tipo) params = params.set('tipo', tipo);
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`, { params });
} }
getById(id: string): Observable<UserDataRow> { getById(id: string): Observable<UserDataRow> {
return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`); return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`);
} }
update(id: string, payload: UpdateUserDataRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/user-data/${id}`, payload);
}
create(payload: CreateUserDataRequest): Observable<UserDataRow> {
return this.http.post<UserDataRow>(`${this.baseApi}/user-data`, payload);
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/user-data/${id}`);
}
} }

View File

@ -0,0 +1,77 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type AuditChangeType = 'added' | 'modified' | 'removed';
export interface AuditFieldChangeDto {
field: string;
changeType: AuditChangeType;
oldValue?: string | null;
newValue?: string | null;
}
export interface AuditLogDto {
id: string;
occurredAtUtc: string;
action: AuditAction | string;
page: string;
entityName: string;
entityId?: string | null;
entityLabel?: string | null;
userId?: string | null;
userName?: string | null;
userEmail?: string | null;
requestPath?: string | null;
requestMethod?: string | null;
ipAddress?: string | null;
changes: AuditFieldChangeDto[];
}
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface HistoricoQuery {
pageName?: string;
action?: AuditAction | string;
entity?: string;
userId?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@Injectable({ providedIn: 'root' })
export class HistoricoService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
list(params: HistoricoQuery): Observable<PagedResult<AuditLogDto>> {
let httpParams = new HttpParams();
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
if (params.action) httpParams = httpParams.set('action', params.action);
if (params.entity) httpParams = httpParams.set('entity', params.entity);
if (params.userId) httpParams = httpParams.set('userId', params.userId);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
httpParams = httpParams.set('page', String(params.page || 1));
httpParams = httpParams.set('pageSize', String(params.pageSize || 10));
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico`, { params: httpParams });
}
}

View File

@ -47,6 +47,8 @@ export interface MobileLineDetail extends MobileLineList {
solicitante?: string | null; solicitante?: string | null;
dataEntregaOpera?: string | null; dataEntregaOpera?: string | null;
dataEntregaCliente?: string | null; dataEntregaCliente?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
} }
export interface LineOption { export interface LineOption {

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@ -20,6 +20,10 @@ export type NotificationDto = {
cliente?: string | null; cliente?: string | null;
linha?: string | null; linha?: string | null;
usuario?: string | null; usuario?: string | null;
conta?: string | null;
planoContrato?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@ -38,4 +42,29 @@ export class NotificationsService {
markAsRead(id: string): Observable<void> { markAsRead(id: string): Observable<void> {
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {}); return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {});
} }
markAllAsRead(filter?: string, notificationIds?: string[]): Observable<void> {
let params = new HttpParams();
if (filter) params = params.set('filter', filter);
const body = notificationIds && notificationIds.length ? { notificationIds } : {};
return this.http.patch<void>(`${this.baseApi}/notifications/read-all`, body, { params });
}
export(filter?: string, notificationIds?: string[]): Observable<HttpResponse<Blob>> {
let params = new HttpParams();
if (filter) params = params.set('filter', filter);
if (notificationIds && notificationIds.length) {
return this.http.post(`${this.baseApi}/notifications/export`, { notificationIds }, {
params,
observe: 'response',
responseType: 'blob'
});
}
return this.http.get(`${this.baseApi}/notifications/export`, {
params,
observe: 'response',
responseType: 'blob'
});
}
} }

View File

@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface ParcelamentoListItem {
id: string;
anoRef?: number | null;
item?: number | null;
linha?: string | null;
cliente?: string | null;
qtParcelas?: string | null;
parcelaAtual?: number | null;
totalParcelas?: number | null;
valorCheio?: number | string | null;
desconto?: number | string | null;
valorComDesconto?: number | string | null;
}
export interface ParcelamentoParcela {
competencia: string;
valor?: number | string | null;
}
export interface ParcelamentoAnnualMonth {
month: number;
valor?: number | string | null;
}
export interface ParcelamentoAnnualRow {
year: number;
total?: number | string | null;
months?: ParcelamentoAnnualMonth[];
}
export interface ParcelamentoMonthInput {
competencia: string;
valor?: number | string | null;
}
export interface ParcelamentoUpsertRequest {
anoRef?: number | null;
item?: number | null;
linha?: string | null;
cliente?: string | null;
qtParcelas?: string | null;
parcelaAtual?: number | null;
totalParcelas?: number | null;
valorCheio?: number | string | null;
desconto?: number | string | null;
valorComDesconto?: number | string | null;
monthValues?: ParcelamentoMonthInput[] | null;
}
export interface ParcelamentoDetail extends ParcelamentoListItem {
parcelasMensais?: ParcelamentoParcela[];
annualRows?: ParcelamentoAnnualRow[];
}
export interface ParcelamentoDetailResponse extends ParcelamentoListItem {
parcelasMensais?: ParcelamentoParcela[];
parcelas?: ParcelamentoParcela[];
monthValues?: ParcelamentoParcela[];
annualRows?: ParcelamentoAnnualRow[];
}
@Injectable({ providedIn: 'root' })
export class ParcelamentosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
list(filters: {
anoRef?: number;
linha?: string;
cliente?: string;
competenciaAno?: number;
competenciaMes?: number;
page?: number;
pageSize?: number;
}): Observable<PagedResult<ParcelamentoListItem>> {
let params = new HttpParams();
if (filters.anoRef !== undefined) params = params.set('anoRef', String(filters.anoRef));
if (filters.linha && filters.linha.trim()) params = params.set('linha', filters.linha.trim());
if (filters.cliente && filters.cliente.trim()) params = params.set('cliente', filters.cliente.trim());
if (filters.competenciaAno !== undefined) params = params.set('competenciaAno', String(filters.competenciaAno));
if (filters.competenciaMes !== undefined) params = params.set('competenciaMes', String(filters.competenciaMes));
params = params.set('page', String(filters.page ?? 1));
params = params.set('pageSize', String(filters.pageSize ?? 10));
return this.http.get<PagedResult<ParcelamentoListItem>>(`${this.baseApi}/parcelamentos`, { params });
}
getById(id: string): Observable<ParcelamentoDetailResponse> {
return this.http.get<ParcelamentoDetailResponse>(`${this.baseApi}/parcelamentos/${id}`);
}
create(payload: ParcelamentoUpsertRequest): Observable<ParcelamentoDetailResponse> {
return this.http.post<ParcelamentoDetailResponse>(`${this.baseApi}/parcelamentos`, payload);
}
update(id: string, payload: ParcelamentoUpsertRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/parcelamentos/${id}`, payload);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/parcelamentos/${id}`);
}
}

View File

@ -0,0 +1,116 @@
import { Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { ResumoService, PlanoContratoResumo, MacrophonyPlan } from './resumo.service';
export type PlanSuggestion = {
franquiaGb?: number | null;
valorPlano?: number | null;
};
@Injectable({ providedIn: 'root' })
export class PlanAutoFillService {
private loaded = false;
private loadingPromise: Promise<void> | null = null;
private planMap = new Map<string, PlanSuggestion>();
private planOptions: string[] = [];
constructor(private resumoService: ResumoService) {}
async load(): Promise<void> {
if (this.loaded) return;
if (this.loadingPromise) return this.loadingPromise;
this.loadingPromise = firstValueFrom(this.resumoService.getResumo())
.then((res) => {
const items: Array<PlanoContratoResumo | MacrophonyPlan> = [
...(res?.planoContratoResumos ?? []),
...(res?.macrophonyPlans ?? [])
];
items.forEach((row) => this.addPlanRule(row));
this.planOptions = Array.from(new Set(this.planOptions)).sort((a, b) =>
a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })
);
this.loaded = true;
})
.catch(() => {
this.loaded = true;
})
.finally(() => {
this.loadingPromise = null;
});
return this.loadingPromise;
}
getPlanOptions(): string[] {
return [...this.planOptions];
}
suggest(planName: string | null | undefined): PlanSuggestion | null {
const plan = (planName ?? '').trim();
if (!plan) return null;
const key = this.normalizePlan(plan);
const fromMap = this.planMap.get(key);
const franquia = fromMap?.franquiaGb ?? this.parseFranquiaFromPlan(plan);
const valorPlano = fromMap?.valorPlano ?? null;
if (franquia == null && valorPlano == null) return null;
return { franquiaGb: franquia ?? null, valorPlano };
}
private addPlanRule(row: PlanoContratoResumo | MacrophonyPlan) {
const plano = (row?.planoContrato ?? '').toString().trim();
if (!plano) return;
const key = this.normalizePlan(plano);
const current = this.planMap.get(key) || {};
const franquia = this.toNumber((row as any).franquiaGb ?? (row as any).gb);
const valorPlano = this.toNumber((row as any).valorIndividualComSvas);
const next: PlanSuggestion = {
franquiaGb: current.franquiaGb ?? franquia ?? null,
valorPlano: current.valorPlano ?? valorPlano ?? null
};
this.planMap.set(key, next);
this.planOptions.push(plano);
}
private normalizePlan(plan: string): string {
return plan.trim().replace(/\s+/g, ' ').toUpperCase();
}
private toNumber(value: any): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const raw = String(value).trim();
if (!raw) return null;
const cleaned = raw.replace(/[^0-9,.-]/g, '').replace(',', '.');
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : null;
}
private parseFranquiaFromPlan(plan: string): number | null {
const match = plan.match(/(\d+(?:[.,]\d+)?)\s*(GB|MB)/i);
if (!match) return null;
const raw = match[1].replace(',', '.');
const unit = match[2].toUpperCase();
const value = parseFloat(raw);
if (!Number.isFinite(value)) return null;
if (unit === 'MB') {
return Number((value / 1000).toFixed(4));
}
return value;
}
}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type ProfileMeDto = {
id: string;
nome: string;
email: string;
};
export type UpdateProfilePayload = {
nome: string;
email: string;
};
export type ChangePasswordPayload = {
credencialAtual: string;
novaCredencial: string;
confirmarNovaCredencial: string;
};
@Injectable({ providedIn: 'root' })
export class ProfileService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getMe(): Observable<ProfileMeDto> {
return this.http.get<ProfileMeDto>(`${this.baseApi}/profile/me`);
}
updateProfile(payload: UpdateProfilePayload): Observable<ProfileMeDto> {
return this.http.patch<ProfileMeDto>(`${this.baseApi}/profile`, payload);
}
changePassword(payload: ChangePasswordPayload): Observable<void> {
return this.http.post<void>(`${this.baseApi}/profile/change-password`, payload);
}
}

View File

@ -0,0 +1,129 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
export interface MacrophonyPlan {
planoContrato?: string | null;
gb?: string | number | null;
valorIndividualComSvas?: string | number | null;
franquiaGb?: string | number | null;
totalLinhas?: number | string | null;
valorTotal?: string | number | null;
vivoTravel?: boolean | string | number | null;
}
export interface MacrophonyTotals {
franquiaGbTotal?: string | number | null;
totalLinhasTotal?: number | string | null;
valorTotal?: string | number | null;
}
export interface VivoLineResumo {
skil?: string | null;
cliente?: string | null;
qtdLinhas?: number | string | null;
franquiaTotal?: string | number | null;
valorContratoVivo?: string | number | null;
franquiaLine?: string | number | null;
valorContratoLine?: string | number | null;
lucro?: string | number | null;
}
export interface VivoLineTotals {
qtdLinhasTotal?: number | string | null;
franquiaTotal?: string | number | null;
valorContratoVivo?: string | number | null;
franquiaLine?: string | number | null;
valorContratoLine?: string | number | null;
lucro?: string | number | null;
}
export interface ClienteEspecial {
nome?: string | null;
valor?: string | number | null;
}
export interface PlanoContratoResumo {
planoContrato?: string | null;
gb?: string | number | null;
valorIndividualComSvas?: string | number | null;
franquiaGb?: string | number | null;
totalLinhas?: number | string | null;
valorTotal?: string | number | null;
}
export interface PlanoContratoTotal {
valorTotal?: string | number | null;
}
export interface LineTotal {
tipo?: string | null;
valorTotalLine?: string | number | null;
lucroTotalLine?: string | number | null;
qtdLinhas?: number | string | null;
}
export interface GbDistribuicao {
gb?: string | number | null;
qtd?: number | string | null;
soma?: string | number | null;
}
export interface GbDistribuicaoTotal {
totalLinhas?: number | string | null;
somaTotal?: string | number | null;
}
export interface ReservaLine {
ddd?: string | number | null;
franquiaGb?: string | number | null;
qtdLinhas?: number | string | null;
total?: string | number | null;
}
export interface ReservaPorFranquia {
franquiaGb?: string | number | null;
totalLinhas?: number | string | null;
}
export interface ReservaPorDdd {
ddd?: string | number | null;
totalLinhas?: number | string | null;
porFranquia?: ReservaPorFranquia[];
}
export interface ReservaTotal {
qtdLinhasTotal?: number | string | null;
total?: string | number | null;
}
export interface ResumoResponse {
macrophonyPlans?: MacrophonyPlan[];
macrophonyTotals?: MacrophonyTotals;
vivoLineResumos?: VivoLineResumo[];
vivoLineTotals?: VivoLineTotals;
clienteEspeciais?: ClienteEspecial[];
planoContratoResumos?: PlanoContratoResumo[];
planoContratoTotal?: PlanoContratoTotal;
lineTotais?: LineTotal[];
gbDistribuicao?: GbDistribuicao[];
gbDistribuicaoTotal?: GbDistribuicaoTotal;
reservaLines?: ReservaLine[];
reservaPorDdd?: ReservaPorDdd[];
totalGeralLinhasReserva?: number | string | null;
reservaTotal?: ReservaTotal;
}
@Injectable({ providedIn: 'root' })
export class ResumoService {
private readonly apiBase: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getResumo() {
return this.http.get<ResumoResponse>(`${this.apiBase}/resumo`);
}
}

View File

@ -0,0 +1,110 @@
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class SessionNoticeService {
private toastEl: HTMLElement | null = null;
private toastBodyEl: HTMLElement | null = null;
private toastHeaderEl: HTMLElement | null = null;
private handling401 = false;
private last401At = 0;
constructor(
private authService: AuthService,
private router: Router,
@Inject(PLATFORM_ID) private platformId: object
) {}
async handleUnauthorized(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
const now = Date.now();
if (this.handling401 && now - this.last401At < 3000) return;
this.handling401 = true;
this.last401At = now;
await this.showToast('Sua sessão expirou. Faça login novamente.', 'danger');
this.authService.logout();
this.router.navigateByUrl('/login');
setTimeout(() => {
this.handling401 = false;
}, 3000);
}
async handleForbidden(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
await this.showToast('Acesso restrito.', 'warning');
}
private ensureToast(): void {
if (!isPlatformBrowser(this.platformId)) return;
if (this.toastEl && this.toastBodyEl && this.toastHeaderEl) return;
const doc = document;
let container = doc.getElementById('lg-global-toast-container');
if (!container) {
container = doc.createElement('div');
container.id = 'lg-global-toast-container';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '10000';
doc.body.appendChild(container);
}
const toast = doc.createElement('div');
toast.className = 'toast text-bg-danger border-0 shadow';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
const header = doc.createElement('div');
header.className = 'toast-header border-bottom-0';
const title = doc.createElement('strong');
title.className = 'me-auto text-primary';
title.textContent = 'LineGestão';
const closeBtn = doc.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn-close';
closeBtn.setAttribute('data-bs-dismiss', 'toast');
closeBtn.setAttribute('aria-label', 'Fechar');
header.appendChild(title);
header.appendChild(closeBtn);
const body = doc.createElement('div');
body.className = 'toast-body bg-white rounded-bottom text-dark';
toast.appendChild(header);
toast.appendChild(body);
container.appendChild(toast);
this.toastEl = toast;
this.toastBodyEl = body;
this.toastHeaderEl = header;
}
private async showToast(message: string, variant: 'danger' | 'warning'): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
this.ensureToast();
if (!this.toastEl || !this.toastBodyEl || !this.toastHeaderEl) return;
this.toastBodyEl.textContent = message;
this.toastEl.classList.remove('text-bg-danger', 'text-bg-warning');
this.toastEl.classList.add(variant === 'warning' ? 'text-bg-warning' : 'text-bg-danger');
try {
const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.toastEl, {
autohide: true,
delay: 3000
});
toastInstance.show();
} catch (error) {
console.error(error);
}
}
}

View File

@ -23,8 +23,24 @@ export interface VigenciaRow {
dtEfetivacaoServico: string | null; dtEfetivacaoServico: string | null;
dtTerminoFidelizacao: string | null; dtTerminoFidelizacao: string | null;
total: number | null; total: number | null;
createdAt?: string | null;
updatedAt?: string | null;
} }
export interface UpdateVigenciaRequest {
item?: number | null;
conta?: string | null;
linha?: string | null;
cliente?: string | null;
usuario?: string | null;
planoContrato?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
total?: number | null;
}
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
export interface VigenciaClientGroup { export interface VigenciaClientGroup {
cliente: string; cliente: string;
linhas: number; linhas: number;
@ -86,4 +102,20 @@ export class VigenciaService {
getClients(): Observable<string[]> { getClients(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/lines/vigencia/clients`); return this.http.get<string[]>(`${this.baseApi}/lines/vigencia/clients`);
} }
getById(id: string): Observable<VigenciaRow> {
return this.http.get<VigenciaRow>(`${this.baseApi}/lines/vigencia/${id}`);
}
update(id: string, payload: UpdateVigenciaRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/lines/vigencia/${id}`, payload);
}
create(payload: CreateVigenciaRequest): Observable<VigenciaRow> {
return this.http.post<VigenciaRow>(`${this.baseApi}/lines/vigencia`, payload);
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`);
}
} }

View File

@ -1,11 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="pt-BR">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>LineGestaoFrontend</title> <title>LineGestão</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/png" href="logo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;700&display=swap" rel="stylesheet">

View File

@ -1,7 +1,11 @@
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser'; import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { registerLocaleData } from '@angular/common';
import localePt from '@angular/common/locales/pt';
import { App } from './app/app'; import { App } from './app/app';
import { config } from './app/app.config.server'; import { config } from './app/app.config.server';
registerLocaleData(localePt, 'pt-BR');
const bootstrap = (context: BootstrapContext) => const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(App, config, context); bootstrapApplication(App, config, context);

View File

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

View File

@ -83,7 +83,12 @@ select.form-control-sm {
/* Empurra o conteúdo pra baixo do header fixo */ /* Empurra o conteúdo pra baixo do header fixo */
.app-main.has-header { .app-main.has-header {
position: relative;
padding-top: 84px; /* altura segura p/ header (mobile/desktop) */ padding-top: 84px; /* altura segura p/ header (mobile/desktop) */
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.1), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.06), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -92,6 +97,21 @@ select.form-control-sm {
} }
} }
/* Ajuste para monitores grandes: elimina o "vão" visual entre header e corpo. */
@media (min-width: 1400px) {
.app-main.has-header {
padding-top: 72px;
}
.container-geral,
.container-geral-responsive,
.container-fat,
.container-mureg,
.container-troca {
margin-top: 14px !important;
}
}
/* ========================================================== */ /* ========================================================== */
/* 🚀 GLOBAL FIX: Proporção Horizontal e Vertical */ /* 🚀 GLOBAL FIX: Proporção Horizontal e Vertical */
/* ========================================================== */ /* ========================================================== */
@ -143,7 +163,14 @@ select.form-control-sm {
.users-page, .users-page,
.fat-page, .fat-page,
.mureg-page, .mureg-page,
.troca-page { .troca-page,
.historico-page,
.perfil-page,
.dashboard-page,
.chips-page,
.parcelamentos-page,
.resumo-page,
.create-user-page {
overflow-y: auto !important; overflow-y: auto !important;
height: auto !important; height: auto !important;
display: block !important; display: block !important;
@ -280,3 +307,23 @@ app-header .modal-card .btn-secondary:hover {
} }
} }
/* Remove separators inside search inputs (icon / text / clear button). */
.input-group.search-group {
> .input-group-text,
> .form-control,
> .btn,
> .btn-clear {
border: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
> :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.invalid-tooltip) {
margin-left: 0 !important;
}
> .form-control:focus {
box-shadow: none !important;
}
}