Commit com novs paginas e funcionalidades

This commit is contained in:
Eduardo 2026-01-09 14:03:47 -03:00
parent 47485a867b
commit 03cdf82cb7
27 changed files with 5161 additions and 625 deletions

19
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
@ -1896,6 +1897,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz",
@ -4299,6 +4306,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View File

@ -34,6 +34,7 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",

View File

@ -8,6 +8,13 @@ import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
import { Parcelamento } from './pages/parcelamento/parcelamento';
// ✅ NOVO: TROCA DE NÚMERO
export const routes: Routes = [
{ path: '', component: Home },
@ -17,6 +24,13 @@ export const routes: Routes = [
{ path: 'geral', component: Geral, canActivate: [authGuard] },
{ path: 'mureg', component: Mureg, canActivate: [authGuard] },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
// ✅ NOVO: rota da página Troca de Número
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
{ path: 'parcelamento', component: Parcelamento, canActivate: [authGuard] },
{ path: '**', redirectTo: '' },
];

View File

@ -91,15 +91,30 @@
<i class="bi bi-sim me-2"></i> Gerenciar Linhas
</a>
<!--NOVO: FATURAMENTO -->
<!--FATURAMENTO -->
<a routerLink="/faturamento" class="side-item" (click)="closeMenu()">
<i class="bi bi-receipt me-2"></i> Faturamento
</a>
<!-- ✅ VIGÊNCIA -->
<a routerLink="/vigencia" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar-check me-2"></i> Vigência
</a>
<a routerLink="/mureg" class="side-item" (click)="closeMenu()">
<i class="bi bi-table me-2"></i> Mureg
</a>
<!-- ✅ TROCA DE NÚMERO -->
<a routerLink="/trocanumero" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right me-2"></i> Troca de Número
</a>
<!-- ✅ NOVO: PARCELAMENTO -->
<a routerLink="/parcelamento" class="side-item" (click)="closeMenu()">
<i class="bi bi-graph-up-arrow me-2"></i> Parcelamento
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-clipboard-data me-2"></i> Controle de Contratos
</a>
@ -108,6 +123,11 @@
<i class="bi bi-people me-2"></i> Gerenciar Clientes
</a>
<!-- ✅ DADOS DOS USUÁRIOS -->
<a routerLink="/dadosusuarios" class="side-item" (click)="closeMenu()">
<i class="bi bi-person-lines-fill me-2"></i> Dados dos Usuários
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-bar-chart me-2"></i> Relatórios
</a>

View File

@ -20,21 +20,35 @@ export class Header {
// ✅ define quando mostrar header “logado”
isLoggedHeader = false;
// ✅ rotas internas que usam menu lateral
private readonly loggedPrefixes = [
'/geral',
'/mureg',
'/faturamento',
'/dadosusuarios',
'/vigencia',
'/trocanumero',
'/parcelamento', // ✅ ADICIONADO: Parcelamento
];
constructor(
private router: Router,
@Inject(PLATFORM_ID) private platformId: object
) {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
const url = event.urlAfterRedirects || event.url;
const rawUrl = event.urlAfterRedirects || event.url;
// normaliza (remove query/hash)
const url = rawUrl.split('?')[0].split('#')[0];
this.isHome = (url === '/' || url === '');
// ✅ considera header logado quando está em rotas internas
// (agora inclui MUREG)
this.isLoggedHeader =
url.startsWith('/geral') ||
url.startsWith('/mureg');
// ✅ considera "logado" se a rota começa com qualquer prefixo interno
// aceita também subrotas, ex: /parcelamento/detalhes/123
this.isLoggedHeader = this.loggedPrefixes.some((p) =>
url === p || url.startsWith(p + '/')
);
// ✅ ao trocar de rota, fecha o menu
this.menuOpen = false;

View File

@ -0,0 +1,209 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-success 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="users-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" data-animate>
<i class="bi bi-people-fill"></i> DADOS USUÁRIOS
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">GESTÃO DE USUÁRIOS</h5>
<small class="subtitle">Base de dados agrupada por cliente</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
</div>
<div class="users-kpis mt-4 animate-fade-in">
<div class="kpi">
<span class="lbl">Total Usuários</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiTotalRegistros || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Clientes Únicos</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiClientesUnicos || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-success">Com CPF</span>
<span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiComCpf || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Com E-mail</span>
<span class="val text-brand">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiComEmail || 0 }}</span>
</span>
</div>
</div>
<div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group">
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span>
<input class="form-control" placeholder="Pesquisar cliente, linha, cpf..." [(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>
</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">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div>
</div>
</div>
</div>
<div class="geral-body">
<div class="groups-container">
<div class="text-center p-5" *ngIf="loading"><span class="spinner-border text-brand"></span></div>
<div class="empty-group" *ngIf="!loading && groups.length === 0">
Nenhum cliente encontrado.
</div>
<div class="group-list" *ngIf="!loading">
<div *ngFor="let g of groups; trackBy: trackByCliente" class="client-group-card" [class.expanded]="expandedGroup === g.cliente">
<div class="group-header" (click)="toggleGroup(g)">
<div class="group-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.totalRegistros }} Registros</span>
<span class="badge-pill ok" *ngIf="g.comCpf > 0">{{ g.comCpf }} CPF</span>
<span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span>
</div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Registros do Cliente</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Visualização detalhada</span>
</div>
<div class="text-center p-4" *ngIf="expandedLoading">
<div class="spinner-border spinner-border-sm text-brand"></div>
</div>
<div class="table-wrap inner-table-wrap" *ngIf="!expandedLoading">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CPF</th>
<th>E-MAIL</th>
<th>CELULAR</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngIf="groupRows.length === 0">
<td colspan="6" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro encontrado.</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha }}</td>
<td class="small font-monospace">{{ r.cpf || '-' }}</td>
<td class="text-muted small td-clip" [title]="r.email">{{ r.email || '-' }}</td>
<td class="text-muted small">{{ r.celular || '-' }}</td>
<td>
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="geral-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} Clientes</div>
<nav><ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading"><button class="page-link" (click)="goToPage(page - 1)">Anterior</button></li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page"><button class="page-link" (click)="goToPage(p)">{{ p }}</button></li>
<li class="page-item" [class.disabled]="page === totalPages || loading"><button class="page-link" (click)="goToPage(page + 1)">Próxima</button></li>
</ul></nav>
</div>
</div>
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="detailsOpen" (click)="closeDetails()"></div>
<div class="modal-custom" *ngIf="detailsOpen">
<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-person-vcard"></i></span>
Detalhes do Usuário
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<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"><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>CPF</label><div>{{ selectedRow?.cpf || '-' }}</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"><label>CELULAR</label><div>{{ selectedRow?.celular || '-' }}</div></div>
<div class="form-field"><label>TELEFONE FIXO</label><div>{{ selectedRow?.telefoneFixo || '-' }}</div></div>
<div class="form-field span-2"><label>ENDEREÇO</label><div>{{ selectedRow?.endereco || '-' }}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,272 @@
/* ========================================================== */
/* VARIÁVEIS E GERAL */
/* ========================================================== */
: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;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--danger-bg: rgba(220, 53, 69, 0.1);
--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;
}
/* LAYOUT PRINCIPAL */
.users-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: 1180px; /* Igual ao Mureg */
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;
}
}
/* HEADER */
.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; }
/* Buttons */
.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.6); border: 1px solid rgba(3, 15, 170, 0.25); color: var(--blue);
&:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; }
}
/* ✅ KPIs (ESTILO MUREG) */
.users-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-brand { color: var(--brand) !important; }
}
.val {
font-size: 1.25rem;
font-weight: 950;
color: var(--text);
&.text-success { color: var(--success-text) !important; }
&.text-brand { color: var(--brand) !important; }
}
}
/* Controls */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.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: var(--muted); padding-left: 14px; padding-right: 8px; display: flex; align-items: center; i { font-size: 1rem; } }
.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; } i { font-size: 1rem; } }
}
.select-glass {
background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(17, 18, 20, 0.15); border-radius: 12px;
color: var(--blue); font-weight: 800; font-size: 0.9rem; text-align: left;
padding: 8px 36px 8px 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); cursor: pointer; transition: all 0.2s ease; width: 100%;
&:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1); }
&:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); }
}
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-icon { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); font-size: 0.75rem; transition: transform 0.2s ease; }
.select-wrapper:hover .select-icon { color: var(--blue); }
/* Animation */
.animate-fade-in { animation: simpleFadeIn 0.5s ease-out forwards; }
@keyframes simpleFadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
/* BODY & GROUPS */
.geral-body { padding: 0; background: transparent; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.groups-container { padding: 16px; overflow-y: auto; height: 100%; }
.group-list { display: flex; flex-direction: column; gap: 12px; }
.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); }
.client-group-card {
background: #fff; border-radius: 16px; border: 1px solid rgba(17,18,20,0.08); overflow: hidden; transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227,61,207,0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227,61,207,0.12); }
}
.group-header { padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background: linear-gradient(180deg, #fff, #fdfdfd); &:hover .group-toggle-icon { color: var(--brand); } }
.group-info { display: flex; flex-direction: column; gap: 6px; }
.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); }
}
.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); }
.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); }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.chip-muted { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); border: 1px solid rgba(17,18,20,0.06); }
.inner-table-wrap { max-height: 450px; overflow-y: auto; }
/* TABLE */
.table-wrap { overflow-x: auto; overflow-y: auto; height: 100%; }
.table-modern {
width: 100%; min-width: 1000px; 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; cursor: pointer; transition: color 0.2s; text-align: center !important; &:hover { color: var(--brand); } }
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 !important; }
}
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.fw-black { font-weight: 950; }
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
.empty-state { background: rgba(255,255,255,0.4); }
.btn-icon {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
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); }
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
}
/* FOOTER */
.geral-footer { padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; flex-shrink: 0; @media (max-width: 768px) { justify-content: center; text-align: center; } }
.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; }
/* MODALS */
.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-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; }
@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-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
/* FORM & DETAILS */
.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.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-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; } }
.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; } }

View File

@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DadosUsuarios } from './dados-usuarios';
describe('DadosUsuarios', () => {
let component: DadosUsuarios;
let fixture: ComponentFixture<DadosUsuarios>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DadosUsuarios], // standalone component
}).compileComponents();
fixture = TestBed.createComponent(DadosUsuarios);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,229 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import {
DadosUsuariosService,
UserDataClientGroup,
UserDataRow,
UserDataGroupResponse,
PagedResult
} from '../../services/dados-usuarios.service';
type ViewMode = 'lines' | 'groups';
@Component({
selector: 'app-dados-usuarios',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
templateUrl: './dados-usuarios.html',
styleUrls: ['./dados-usuarios.scss'],
providers: [DadosUsuariosService]
})
export class DadosUsuarios implements OnInit {
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
loading = false;
errorMsg = '';
// Filtros
search = '';
// Paginação
page = 1;
pageSize = 10;
total = 0;
// Ordenação
sortBy = 'cliente';
sortDir: 'asc' | 'desc' = 'asc';
// PADRÃO: GROUPS (Acordeão)
viewMode: ViewMode = 'groups';
// Dados
groups: UserDataClientGroup[] = [];
rows: UserDataRow[] = [];
// KPIs
kpiTotalRegistros = 0;
kpiClientesUnicos = 0;
kpiComCpf = 0;
kpiComEmail = 0;
// ACORDEÃO
expandedGroup: string | null = null;
expandedLoading = false;
groupRows: UserDataRow[] = [];
// Modal / Toast
detailsOpen = false;
selectedRow: UserDataRow | null = null;
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: any = null;
private searchTimer: any = null;
constructor(private service: DadosUsuariosService) {}
ngOnInit(): void {
this.fetch(1);
}
// Alternar Visualização
setView(mode: ViewMode): void {
if (this.viewMode === mode) return;
this.viewMode = mode;
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.sortBy = mode === 'groups' ? 'cliente' : 'item';
this.fetch(1);
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
}
get pageStart(): number { return (this.page - 1) * this.pageSize + 1; }
get pageEnd(): number {
const end = this.page * this.pageSize;
return end > this.total ? this.total : end;
}
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;
}
fetch(goToPage?: number): void {
if (goToPage) this.page = goToPage;
this.loading = true;
if(goToPage && goToPage !== this.page) this.expandedGroup = null;
if (this.viewMode === 'groups') {
this.fetchGroups();
} else {
this.fetchLines(); // Fallback se quiser usar
}
}
refresh() {
this.fetch(1);
}
private fetchGroups() {
this.service.getGroups({
search: this.search?.trim(),
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDir: this.sortDir,
}).subscribe({
next: (res: UserDataGroupResponse) => {
this.groups = res.data.items || [];
this.total = res.data.total || 0;
this.kpiTotalRegistros = res.kpis.totalRegistros;
this.kpiClientesUnicos = res.kpis.clientesUnicos;
this.kpiComCpf = res.kpis.comCpf;
this.kpiComEmail = res.kpis.comEmail;
this.loading = false;
},
error: (err: HttpErrorResponse) => {
this.loading = false;
this.showToast('Erro ao carregar dados.', 'danger');
}
});
}
private fetchLines() {
// Implementação opcional para modo lista plana
}
toggleGroup(g: UserDataClientGroup): void {
if (this.expandedGroup === g.cliente) {
this.expandedGroup = null;
this.groupRows = [];
return;
}
this.expandedGroup = g.cliente;
this.expandedLoading = true;
this.groupRows = [];
this.service.getRows({
client: g.cliente,
page: 1,
pageSize: 200,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res: PagedResult<UserDataRow>) => {
this.groupRows = res.items || [];
this.expandedLoading = false;
},
error: (err: HttpErrorResponse) => {
this.showToast('Erro ao carregar usuários do cliente.', 'danger');
this.expandedLoading = false;
}
});
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.expandedGroup = null;
this.fetch();
}, 400);
}
clearFilters() { this.search = ''; this.fetch(1); }
onPageSizeChange() {
this.page = 1;
this.fetch();
}
goToPage(p: number) {
this.page = p;
this.fetch();
}
openDetails(row: UserDataRow) {
this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => {
this.selectedRow = fullData;
this.detailsOpen = true;
},
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
});
}
closeDetails() { this.detailsOpen = false; }
trackById(_: number, row: UserDataRow) { return row.id; }
trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; }
showToast(msg: string, type: 'success' | 'danger') {
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
if(this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => this.toastOpen = false, 3000);
}
hideToast() { this.toastOpen = false; }
}

View File

@ -1,121 +1,189 @@
<section class="billing-page">
<!-- faturamento.html (COMPLETO - tabela enxuta + modal detalhes completo) -->
<!-- Toast (Sucesso) -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-success 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="fat-page" (click)="closeClientDropdown()">
<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-billing">
<div class="billing-card" data-animate>
<div class="container-fat">
<div class="fat-card" data-animate>
<!-- HEADER (igual Geral) -->
<div class="billing-header">
<div class="fat-header">
<div class="header-row-top">
<div class="title-badge" data-animate>
<i class="bi bi-cash-stack"></i> Financeiro
<i class="bi bi-cash-coin"></i> Faturamento
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">Faturamento</h5>
<small class="subtitle">Visualize dados de Pessoa Física e Pessoa Jurídica</small>
<small class="subtitle">Totais, lucro e comparativo Vivo x Line</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button class="btn btn-glass btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate></div>
</div>
<!-- FILTROS (tabs + controls no mesmo padrão do Geral) -->
<!-- FILTROS -->
<div class="filters-row mt-4" data-animate>
<!-- Tipo PF/PJ como tabs -->
<div class="filter-tabs">
<button
type="button"
class="filter-tab"
[class.active]="tipo === 'PF'"
(click)="setTipo('PF')"
[disabled]="loading">
<button type="button" class="filter-tab" [class.active]="filterTipo === 'ALL'" (click)="setFilter('ALL')" [disabled]="loading">
Todos
</button>
<button type="button" class="filter-tab" [class.active]="filterTipo === 'PF'" (click)="setFilter('PF')" [disabled]="loading">
<i class="bi bi-person me-1"></i> Pessoa Física
</button>
<button
type="button"
class="filter-tab"
[class.active]="tipo === 'PJ'"
(click)="setTipo('PJ')"
[disabled]="loading">
<button type="button" class="filter-tab" [class.active]="filterTipo === 'PJ'" (click)="setFilter('PJ')" [disabled]="loading">
<i class="bi bi-building me-1"></i> Pessoa Jurídica
</button>
</div>
<!-- Cliente (select glass) -->
<div class="client-filter">
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="client"
(change)="applyClientFilter()"
[disabled]="loading"
title="Filtrar por cliente">
<option value="">Todos os clientes</option>
<option *ngFor="let c of clients" [value]="c">{{ c }}</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<!-- CLIENTE MULTI-SELECT -->
<div class="client-filter-wrap" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-client-filter"
[class.has-selection]="selectedClients.length > 0"
(click)="toggleClientMenu()"
[disabled]="loading">
<ng-container *ngIf="selectedClients.length === 0">
<i class="bi bi-people-fill me-2"></i>
<span>Clientes</span>
<i class="bi bi-chevron-down ms-2 small"></i>
</ng-container>
<ng-container *ngIf="selectedClients.length > 0">
<div class="chips-container">
<span *ngFor="let client of selectedClients" class="client-chip" (click)="$event.stopPropagation()">
{{ client }}
<i class="bi bi-x chip-close" (click)="removeClient(client, $event)"></i>
</span>
</div>
<i class="bi bi-chevron-down ms-1 small text-muted"></i>
</ng-container>
</button>
<div class="client-dropdown" *ngIf="showClientMenu">
<div class="dropdown-header-search">
<input
type="text"
class="form-control form-control-sm"
placeholder="Buscar na lista..."
[(ngModel)]="clientSearchTerm"
autofocus
(click)="$event.stopPropagation()">
</div>
<button
class="btn btn-outline-secondary btn-sm btn-clear-client"
type="button"
(click)="clearClient()"
[disabled]="loading || !client"
title="Limpar filtro de cliente">
<i class="bi bi-x-circle"></i>
<div class="dropdown-list">
<div class="dropdown-item-custom" [class.selected]="selectedClients.length === 0" (click)="selectClient(null)">
<i class="bi bi-grid me-2"></i> Todos os Clientes
</div>
<ng-container *ngFor="let client of filteredClientsList">
<div class="dropdown-item-custom" [class.selected]="isClientSelected(client)" (click)="selectClient(client)">
<div class="d-flex align-items-center justify-content-between w-100">
<span>{{ client }}</span>
<i class="bi bi-check-lg text-brand" *ngIf="isClientSelected(client)"></i>
</div>
</div>
</ng-container>
</div>
<div class="dropdown-footer" *ngIf="selectedClients.length > 0">
<button class="btn btn-outline-secondary btn-sm w-100" (click)="clearClientSelection($event)">
<i class="bi bi-x-circle me-1"></i> Limpar seleção
</button>
</div>
</div>
</div>
</div>
<!-- CONTROLS (search + page size igual Geral) -->
<!-- KPIs -->
<div class="fat-kpis mt-4 animate-fade-in">
<div class="kpi">
<span class="lbl">Total Clientes</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Total Linhas</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
</span>
</div>
<div class="kpi kpi-wide">
<span class="lbl text-vivo">Total Vivo</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ formatMoney(kpiTotalVivo) }}</span>
</span>
</div>
<div class="kpi kpi-wide">
<span class="lbl text-line">Total Line</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ formatMoney(kpiTotalLine) }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Lucro</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ formatMoney(kpiLucro) }}</span>
</span>
</div>
</div>
<!-- CONTROLS -->
<div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i
class="bi"
<i class="bi"
[class.bi-search]="!loading"
[class.bi-hourglass-split]="loading"
[class.text-brand]="loading"></i>
</span>
<input
type="text"
class="form-control"
placeholder="Buscar cliente..."
[(ngModel)]="search"
(ngModelChange)="onSearchChange()"
/>
placeholder="Pesquisar por cliente, aparelho, forma de pagamento..."
[(ngModel)]="searchTerm"
(ngModelChange)="onSearch()" />
<button
class="btn btn-outline-secondary btn-clear"
type="button"
(click)="search=''; onSearchChange()"
[disabled]="loading || !search"
title="Limpar busca">
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase"
style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">
Clientes por pág:
</span>
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="pageSize"
(change)="changePageSize()"
[disabled]="loading">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
@ -125,164 +193,339 @@
</div>
</div>
</div>
</div>
<!-- ERRO -->
<div *ngIf="errorMessage" class="alert alert-danger mt-3 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
{{ errorMessage }}
<!-- BODY: GRUPOS -->
<div class="fat-body">
<div class="groups-container">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!loading && pagedClientGroups.length === 0">
Nenhum dado encontrado.
</div>
<div class="group-list" *ngIf="!loading">
<div
*ngFor="let g of pagedClientGroups; trackBy: trackByCliente"
class="client-group-card"
[class.expanded]="expandedGroup === g.cliente">
<div class="group-header" (click)="toggleGroup(g.cliente)">
<div class="group-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.total }} Registros</span>
<span class="badge-pill lines">{{ g.linhas }} Linhas</span>
<span class="badge-pill vivo">{{ formatMoney(g.totalVivo) }}</span>
<span class="badge-pill line">{{ formatMoney(g.totalLine) }}</span>
<span class="badge-pill lucro" *ngIf="g.lucro !== 0">{{ formatMoney(g.lucro) }}</span>
</div>
</div>
<!-- BODY (tabela no padrão Geral) -->
<div class="billing-body">
<div class="table-meta" *ngIf="result">
<span class="meta-pill">
<i class="bi bi-collection me-1"></i>
Total: <strong>{{ result.total }}</strong>
</span>
<span class="meta-pill">
<i class="bi bi-layers me-1"></i>
Página: <strong>{{ page }}</strong> / <strong>{{ totalPages }}</strong>
</span>
<span class="meta-loading" *ngIf="loading">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando...
</span>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="table-wrap table-wrap-tall">
<table class="table table-modern align-middle text-center mb-0 billing-table">
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Registros do Cliente</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Clique no “olho” para ver todos os detalhes</span>
</div>
<div class="table-wrap inner-table-wrap">
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr>
<th class="sortable" (click)="toggleSort('item')">
<div class="th-content justify-content-center">
ITEM
<span class="sort-caret" [class.active]="sortBy==='item'">
<i [class]="sortIcon('item')"></i>
</span>
</div>
<tr class="thead-group">
<th rowspan="2" class="sortable" (click)="setSort('item')">
<div class="th-content">ITEM <span class="sort-caret" [class.active]="sortBy==='item'">{{ sortBy==='item' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable" (click)="toggleSort('cliente')">
<div class="th-content justify-content-center">
CLIENTE
<span class="sort-caret" [class.active]="sortBy==='cliente'">
<i [class]="sortIcon('cliente')"></i>
</span>
</div>
<th rowspan="2" class="sortable" (click)="setSort('qtdlinhas')">
<div class="th-content">QTD LINHAS <span class="sort-caret" [class.active]="sortBy==='qtdlinhas'">{{ sortBy==='qtdlinhas' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable" (click)="toggleSort('qtdlinhas')">
<div class="th-content justify-content-center">
QTD LINHAS
<span class="sort-caret" [class.active]="sortBy==='qtdlinhas'">
<i [class]="sortIcon('qtdlinhas')"></i>
</span>
</div>
<th colspan="2" class="th-block th-vivo">VIVO</th>
<th colspan="2" class="th-block th-line">LINE MÓVEL</th>
<th rowspan="2">AÇÕES</th>
</tr>
<tr class="thead-sub">
<th class="sortable" (click)="setSort('franquiavivo')">
<div class="th-content">FRANQUIA <span class="sort-caret" [class.active]="sortBy==='franquiavivo'">{{ sortBy==='franquiavivo' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable" (click)="toggleSort('franquiavivo')">
<div class="th-content justify-content-center">
FRANQUIA VIVO
<span class="sort-caret" [class.active]="sortBy==='franquiavivo'">
<i [class]="sortIcon('franquiavivo')"></i>
</span>
</div>
<th class="sortable" (click)="setSort('valorcontratovivo')">
<div class="th-content">VALOR (R$) <span class="sort-caret" [class.active]="sortBy==='valorcontratovivo'">{{ sortBy==='valorcontratovivo' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable" (click)="toggleSort('valorcontratovivo')">
<div class="th-content justify-content-center">
CONTRATO VIVO
<span class="sort-caret" [class.active]="sortBy==='valorcontratovivo'">
<i [class]="sortIcon('valorcontratovivo')"></i>
</span>
</div>
<th class="sortable" (click)="setSort('franquialine')">
<div class="th-content">FRANQUIA <span class="sort-caret" [class.active]="sortBy==='franquialine'">{{ sortBy==='franquialine' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable" (click)="toggleSort('franquialine')">
<div class="th-content justify-content-center">
FRANQUIA LINE
<span class="sort-caret" [class.active]="sortBy==='franquialine'">
<i [class]="sortIcon('franquialine')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('valorcontratoline')">
<div class="th-content justify-content-center">
CONTRATO LINE
<span class="sort-caret" [class.active]="sortBy==='valorcontratoline'">
<i [class]="sortIcon('valorcontratoline')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('lucro')">
<div class="th-content justify-content-center">
LUCRO
<span class="sort-caret" [class.active]="sortBy==='lucro'">
<i [class]="sortIcon('lucro')"></i>
</span>
</div>
</th>
<th>
APARELHO
</th>
<th>
PAGAMENTO
<th class="sortable" (click)="setSort('valorcontratoline')">
<div class="th-content">VALOR (R$) <span class="sort-caret" [class.active]="sortBy==='valorcontratoline'">{{ sortBy==='valorcontratoline' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (result.items?.length ?? 0) === 0">
<td colspan="10" class="text-center py-5 empty-state text-muted fw-bold">
<i class="bi bi-inbox me-2"></i>
Nenhum registro encontrado.
<tr *ngIf="groupRows.length === 0">
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro.</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black">{{ r.qtdLinhas ?? 0 }}</td>
<td class="fw-bold">{{ formatFranquia(r.franquiaVivo) }}</td>
<td class="fw-bold text-vivo">{{ formatMoney(r.valorContratoVivo) }}</td>
<td class="fw-bold">{{ formatFranquia(r.franquiaLine) }}</td>
<td class="fw-bold text-line">{{ formatMoney(r.valorContratoLine) }}</td>
<td>
<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 success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
</div>
</td>
</tr>
<tr *ngFor="let it of result.items; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ it.item }}</td>
<td class="fw-black text-dark td-clip" [title]="it.cliente || ''">{{ it.cliente }}</td>
<td class="fw-bold">{{ it.qtdLinhas ?? 0 }}</td>
<td class="text-end">{{ brl(it.franquiaVivo) }}</td>
<td class="text-end">{{ brl(it.valorContratoVivo) }}</td>
<td class="text-end">{{ brl(it.franquiaLine) }}</td>
<td class="text-end">{{ brl(it.valorContratoLine) }}</td>
<td class="text-end lucro">{{ brl(it.lucro) }}</td>
<td class="text-truncate extra" [title]="it.aparelho || ''">{{ it.aparelho || '-' }}</td>
<td class="text-truncate extra" [title]="it.formaPagamento || ''">{{ it.formaPagamento || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- FOOTER / PAGINAÇÃO (padrão Geral) -->
<div class="billing-footer">
<div class="small text-muted fw-bold">
Página {{ page }} de {{ totalPages }}
</div>
</div>
</div>
</div>
<div class="fat-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }}</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="loading || page <= 1">
<button class="page-link" (click)="prevPage()">Anterior</button>
<li class="page-item" [class.disabled]="page === 1 || loading">
<button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" [class.disabled]="loading || page >= totalPages">
<button class="page-link" (click)="nextPage()">Próxima</button>
<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>
<!-- MODAIS -->
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()"></div>
<div class="modal-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()">
<!-- DETAIL MODAL -->
<div *ngIf="detailOpen" #detailModal 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-receipt"></i></span>
Detalhes do Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="detailData; else detailLoading">
<div class="mb-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex flex-column">
<div class="fw-black" style="font-size: 1.05rem;">
{{ detailData.cliente || '—' }}
</div>
<small class="text-muted fw-bold">
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
</small>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge-pill vivo"><i class="bi bi-telephone-fill me-1"></i> {{ formatMoney(detailData.valorContratoVivo) }}</span>
<span class="badge-pill line"><i class="bi bi-hdd-network-fill me-1"></i> {{ formatMoney(detailData.valorContratoLine) }}</span>
<span class="badge-pill lucro" *ngIf="hasLucro(detailData)">
<i class="bi bi-cash-stack me-1"></i> {{ formatMoney(detailData.lucro) }}
</span>
</div>
</div>
<div class="details-dashboard details-2col">
<!-- IDENTIFICAÇÃO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val text-dark" [title]="detailData.cliente || ''">{{ detailData.cliente || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ detailData.tipo || '—' }}</span>
</div>
<div class="info-item">
<span class="lbl">Qtd Linhas</span>
<span class="val">{{ detailData.qtdLinhas ?? 0 }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Aparelho</span>
<span class="val" [title]="detailData.aparelho || ''">{{ detailData.aparelho || '—' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Forma de Pagamento</span>
<span class="val" [title]="detailData.formaPagamento || ''">{{ detailData.formaPagamento || '—' }}</span>
</div>
</div>
</div>
</div>
<!-- VIVO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-telephone-fill me-2"></i> Vivo</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Franquia Vivo</span>
<span class="val fw-black">{{ formatFranquia(detailData.franquiaVivo) }}</span>
</div>
<div class="info-item">
<span class="lbl text-vivo">Valor Vivo (R$)</span>
<span class="val fw-black text-vivo">{{ formatMoney(detailData.valorContratoVivo) }}</span>
</div>
</div>
</div>
</div>
<!-- LINE -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-hdd-network-fill me-2"></i> Line Móvel</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Franquia Line</span>
<span class="val fw-black">{{ formatFranquia(detailData.franquiaLine) }}</span>
</div>
<div class="info-item">
<span class="lbl text-line">Valor Line (R$)</span>
<span class="val fw-black text-line">{{ formatMoney(detailData.valorContratoLine) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl text-brand">Lucro</span>
<span class="val fw-black text-brand">{{ formatMoney(detailData.lucro) }}</span>
</div>
</div>
</div>
</div>
<!-- RESUMO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-info-circle me-2"></i> Resumo</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Observação</span>
<span class="val">{{ getObservacao(detailData) }}</span>
</div>
</div>
<div class="mt-3 d-flex justify-content-end gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" (click)="closeAllModals()">
Fechar
</button>
<button class="btn btn-primary btn-sm" (click)="onComparativo(detailData)">
<i class="bi bi-columns-gap me-1"></i> Abrir Comparativo
</button>
</div>
</div>
</div>
</div>
</div>
<ng-template #detailLoading>
<div class="p-5 text-center text-muted">Carregando detalhes...</div>
</ng-template>
</div>
<!-- COMPARATIVO MODAL -->
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg success"><i class="bi bi-columns-gap"></i></span> Comparativo Vivo x Line
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="compareData; else compareLoading">
<div class="finance-dashboard">
<div class="finance-card vivo-card">
<div class="card-header-f"><i class="bi bi-telephone-fill me-2"></i> Vivo</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia</span> <strong>{{ formatFranquia(compareData.franquiaVivo) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Vivo (R$)</span> <strong>{{ formatMoney(compareData.valorContratoVivo) }}</strong></div>
</div>
</div>
<div class="finance-card line-card">
<div class="card-header-f"><i class="bi bi-hdd-network-fill me-2"></i> Line Móvel</div>
<div class="card-body-f">
<div class="row-item"><span>Franquia Line</span> <strong>{{ formatFranquia(compareData.franquiaLine) }}</strong></div>
<div class="divider"></div>
<div class="row-item total"><span>Valor Line (R$)</span> <strong>{{ formatMoney(compareData.valorContratoLine) }}</strong></div>
</div>
</div>
</div>
<div class="finance-summary mt-3">
<div class="summary-item">
<span class="lbl">Forma de Pagamento</span>
<span class="val text-dark">{{ compareData.formaPagamento || '—' }}</span>
</div>
<div class="vertical-line"></div>
<div class="summary-item">
<span class="lbl">Lucro</span>
<span class="val text-brand">{{ formatMoney(compareData.lucro) }}</span>
</div>
</div>
</div>
<ng-template #compareLoading>
<div class="p-5 text-center text-muted">Carregando comparativo...</div>
</ng-template>
</div>
</div>

View File

@ -4,10 +4,19 @@
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--radius-xl: 22px;
--radius-lg: 16px;
--radius-md: 12px;
--bg-vivo: #fbf5fc;
--text-vivo: #8a2be2;
--bg-line: #f5f6ff;
--text-line: #030FAA;
--success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--radius-xl: 22px;
--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);
@ -21,8 +30,11 @@
box-sizing: border-box;
}
/* PAGE BG igual Geral */
.billing-page {
/* PAGE BG */
.fat-page,
.fat-page * { box-sizing: border-box; }
.fat-page {
min-height: 100vh;
padding: 0 12px var(--page-bottom-gap);
display: flex;
@ -44,7 +56,6 @@
}
}
/* BLOBS igual Geral */
.page-blob {
position: fixed;
pointer-events: none;
@ -67,7 +78,7 @@
100% { transform: translate(0, 0) scale(1); }
}
.container-billing {
.container-fat {
width: 100%;
max-width: 1180px;
position: relative;
@ -76,8 +87,7 @@
margin-bottom: var(--page-bottom-gap);
}
/* CARD glass */
.billing-card {
.fat-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
@ -101,8 +111,7 @@
}
}
/* HEADER */
.billing-header {
.fat-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));
@ -164,24 +173,7 @@
font-weight: 700;
}
.header-actions { justify-self: end; }
/* BOTÕES */
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(3, 15, 170, 0.25);
color: var(--blue);
&:hover {
transform: translateY(-2px);
border-color: var(--brand);
background: #fff;
}
}
/* FILTROS */
/* FILTERS */
.filters-row {
display: flex;
justify-content: center;
@ -213,10 +205,7 @@
align-items: center;
gap: 6px;
&:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.5);
}
&:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); }
&.active {
background: #fff;
@ -224,24 +213,105 @@
box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
/* CLIENTE: select glass + botão limpar */
.client-filter {
/* CLIENT MULTI SELECT */
.client-filter-wrap { position: relative; }
.btn-client-filter {
display: flex;
align-items: center;
gap: 8px;
}
.btn-clear-client {
padding: 6px 12px;
border-radius: 12px;
border: 1px solid rgba(17, 18, 20, 0.08);
background: rgba(255, 255, 255, 0.6);
color: var(--muted);
font-weight: 700;
font-size: 0.85rem;
backdrop-filter: blur(8px);
transition: all 0.2s;
min-height: 38px;
height: auto;
flex-wrap: wrap;
&:hover { background: #fff; border-color: var(--blue); color: var(--blue); }
&.has-selection { background: #fff; border-color: var(--brand); }
}
/* CONTROLS igual Geral */
.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 520px; }
.client-chip {
display: inline-flex;
align-items: center;
background: rgba(227, 61, 207, 0.1);
color: var(--brand);
border: 1px solid rgba(227, 61, 207, 0.2);
border-radius: 6px;
padding: 2px 6px;
font-size: 0.75rem;
font-weight: 800;
user-select: none;
}
.chip-close {
margin-left: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 14px;
height: 14px;
&:hover { background: rgba(227, 61, 207, 0.2); color: #b91f9b; }
}
.client-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 340px;
max-height: 430px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border: 1px solid rgba(17,18,20,0.08);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.dropdown-header-search {
padding: 8px;
border-bottom: 1px solid rgba(0,0,0,0.05);
background: #f9fafb;
}
.dropdown-list { overflow-y: auto; max-height: 310px; }
.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);
&: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-footer { padding: 10px; border-top: 1px solid rgba(0,0,0,0.05); background: #fff; }
/* CONTROLS */
.controls {
display: flex;
gap: 12px;
@ -251,7 +321,7 @@
}
.search-group {
max-width: 320px;
max-width: 360px;
border-radius: 12px;
overflow: hidden;
display: flex;
@ -259,7 +329,6 @@
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);
@ -275,8 +344,6 @@
padding-right: 8px;
display: flex;
align-items: center;
i { font-size: 1rem; }
}
.form-control {
@ -299,62 +366,28 @@
padding: 0 12px;
align-items: center;
cursor: pointer;
transition: color 0.2s;
&:hover { color: #dc3545; }
i { font-size: 1rem; }
}
}
.page-size {
margin-left: auto;
@media (max-width: 500px) {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
}
.select-wrapper {
position: relative;
display: inline-block;
min-width: 170px;
@media (max-width: 500px) {
min-width: 150px;
}
}
.page-size { margin-left: auto; }
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(17, 18, 20, 0.15);
border-radius: 12px;
color: var(--blue);
font-weight: 800;
font-size: 0.9rem;
text-align: left;
padding: 8px 36px 8px 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover {
background: #fff;
border-color: var(--blue);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1);
}
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
}
&:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); }
&:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); }
}
.select-icon {
@ -367,12 +400,63 @@
font-size: 0.75rem;
}
.text-brand { color: var(--brand) !important; }
.fw-black { font-weight: 950; }
/* KPIs */
.fat-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
width: 100%;
@media (max-width: 1100px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { 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);
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
min-width: 160px;
&: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);
white-space: nowrap;
}
.val {
font-size: 1.12rem;
font-weight: 950;
color: var(--text);
white-space: nowrap;
}
}
.kpi-wide {
min-width: 220px;
padding: 14px 18px;
.val { font-size: 1.18rem; }
}
/* BODY */
.billing-body {
padding: 16px;
.fat-body {
padding: 0;
background: transparent;
flex: 1;
overflow: hidden;
@ -381,58 +465,114 @@
min-height: 0;
}
/* meta pills */
.table-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(17,18,20,0.10);
font-weight: 800;
color: rgba(17,18,20,0.75);
i { color: var(--brand); }
}
.meta-loading {
margin-left: auto;
display: inline-flex;
align-items: center;
color: rgba(17,18,20,0.65);
font-weight: 700;
}
/* TABELA igual Geral */
.table-wrap {
overflow: auto;
/* ========================================================= */
/* ✅ GROUP VIEW (MUREG STYLE) */
/* ========================================================= */
.groups-container {
padding: 16px;
overflow-y: auto;
height: 100%;
}
.table-wrap-tall {
flex: 1 1 auto;
min-height: 0;
max-height: clamp(760px, 82vh, 1800px) !important;
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(17,18,20,0.10);
background: rgba(255,255,255,0.7);
backdrop-filter: blur(6px);
overscroll-behavior: contain;
.group-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.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);
}
.client-group-card {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(17,18,20,0.08);
overflow: hidden;
transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227,61,207,0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227,61,207,0.12); }
}
.group-header {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background: linear-gradient(180deg, #fff, #fdfdfd);
&:hover .group-toggle-icon { color: var(--brand); }
}
.group-info { display: flex; flex-direction: column; gap: 6px; }
.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); }
&.lines { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.75); }
&.vivo { background: rgba(227,61,207,0.12); color: var(--brand); }
&.line { background: rgba(3,15,170,0.08); color: var(--blue); }
&.lucro { background: var(--success-bg); color: var(--success-text); }
}
.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);
}
.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);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.chip-muted {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 800;
color: rgba(17,18,20,0.55);
padding: 4px 10px;
border-radius: 999px;
background: rgba(17,18,20,0.04);
border: 1px solid rgba(17,18,20,0.06);
}
.inner-table-wrap { max-height: 520px; overflow: auto; }
/* TABLE */
.table-wrap { overflow: auto; height: 100%; }
.table-modern {
width: 100%;
min-width: 1100px;
min-width: 1200px;
border-collapse: separate;
border-spacing: 0;
@ -445,26 +585,22 @@
border-bottom: 2px solid rgba(227, 61, 207, 0.15);
padding: 12px;
color: rgba(17, 18, 20, 0.7);
font-size: 0.78rem;
font-size: 0.8rem;
font-weight: 950;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
cursor: default;
text-align: center !important;
}
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;
@ -472,51 +608,34 @@
color: var(--text);
text-align: center !important;
}
.sortable {
cursor: pointer;
user-select: none;
&:hover { color: var(--brand); }
}
}
.th-content {
.th-content { display: flex; align-items: center; justify-content: center; gap: 4px; }
.sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } }
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
.empty-state { background: rgba(255,255,255,0.4); }
/* ACTIONS */
.action-group { display: flex; justify-content: center; gap: 6px; }
.btn-icon {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
color: rgba(17,18,20,0.5);
transition: all 0.2s;
.sort-caret {
opacity: 0.55;
&.active {
opacity: 1;
color: var(--brand);
}
}
.td-clip {
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.extra {
max-width: 170px;
}
.empty-state {
background: rgba(255,255,255,0.4);
}
.lucro {
font-weight: 950;
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.success:hover { color: var(--success-text); background: var(--success-bg); }
}
/* FOOTER */
.billing-footer {
.fat-footer {
padding: 14px 24px;
border-top: 1px solid rgba(17, 18, 20, 0.06);
display: flex;
@ -526,10 +645,7 @@
flex-wrap: wrap;
flex-shrink: 0;
@media (max-width: 768px) {
justify-content: center;
text-align: center;
}
@media (max-width: 768px) { justify-content: center; text-align: center; }
}
.pagination-modern .page-link {
@ -540,27 +656,235 @@
background: rgba(255,255,255,0.6);
margin: 0 2px;
&:hover {
transform: translateY(-1px);
border-color: var(--brand);
&: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;
}
/* UTIL COLORS */
.text-brand { color: var(--brand) !important; }
.text-vivo { color: var(--text-vivo) !important; }
.text-line { color: var(--blue) !important; }
.fw-black { font-weight: 950; }
/* MODALS (mantidos do seu arquivo) */
.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-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;
width: min(900px, 100%);
max-height: 90vh;
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@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;
background: rgba(3, 15, 170, 0.1);
color: var(--blue);
display: flex;
align-items: center;
justify-content: center;
&.success { background: var(--success-bg); color: var(--success-text); }
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
}
.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-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
/* detalhes e comparativo (mantidos) */
.details-dashboard {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
@media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 700px) { grid-template-columns: 1fr; }
}
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
.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;
}
.box-header.justify-content-center {
justify-content: center !important;
text-align: center;
background: rgba(227, 61, 207, 0.04);
color: var(--brand);
padding: 8px;
i { margin-right: 8px; color: var(--brand); }
}
.box-body { padding: 16px; }
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.info-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 8px;
background: rgba(245, 245, 247, 0.5);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.03);
&.span-2 { grid-column: span 2; }
.lbl {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--muted);
margin-bottom: 2px;
white-space: nowrap;
}
.val { font-size: 0.9rem; font-weight: 700; color: var(--text); word-break: break-word; }
}
/* Comparativo */
.finance-dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
@media(max-width: 700px) { grid-template-columns: 1fr; }
}
.finance-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid rgba(0,0,0,0.04);
overflow: hidden;
&.vivo-card {
border-top: 4px solid var(--brand);
.card-header-f { color: var(--brand); background: var(--bg-vivo); }
}
&.line-card {
border-top: 4px solid var(--blue);
.card-header-f { color: var(--blue); background: var(--bg-line); }
}
}
.pagination-modern .page-item.disabled .page-link {
opacity: 0.6;
.card-header-f { padding: 12px 16px; font-weight: 800; font-size: 0.95rem; display: flex; align-items: center; }
.card-body-f {
padding: 16px;
.row-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 0.85rem;
color: var(--muted);
strong { color: var(--text); font-weight: 700; }
&.total {
font-size: 1rem;
color: var(--text);
margin-top: 8px;
margin-bottom: 0;
strong { font-weight: 900; }
}
}
.divider { height: 1px; background: rgba(0,0,0,0.06); margin: 12px 0; }
}
/* RESPONSIVO */
@media (max-width: 992px) {
.table-modern { min-width: 1000px; }
.finance-summary {
background: #fff;
border-radius: 16px;
padding: 16px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,0.03);
display: flex;
align-items: center;
justify-content: space-around;
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
.lbl { font-size: 0.75rem; text-transform: uppercase; font-weight: 800; color: var(--muted); }
.val { font-size: 1.05rem; font-weight: 900; }
}
.vertical-line { width: 1px; height: 40px; background: rgba(0,0,0,0.08); }
}
/* Mobile */
@media (max-width: 576px) {
:host {
--page-top-gap: 16px;
--page-bottom-gap: 140px;
}
.table-wrap-tall { max-height: 70vh !important; }
:host { --page-top-gap: 16px; --page-bottom-gap: 140px; }
.container-fat { max-width: 100%; }
.table-modern { min-width: 980px; }
.inner-table-wrap { max-height: 68vh; }
}

View File

@ -1,208 +1,607 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
ViewChild,
Inject,
PLATFORM_ID,
AfterViewInit,
ChangeDetectorRef,
OnDestroy,
HostListener
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { HttpClientModule } from '@angular/common/http';
import {
BillingService,
BillingItem,
PagedResult,
BillingSortBy,
SortDir,
TipoCliente
TipoCliente,
TipoFiltro
} from '../../services/billing';
interface BillingClientGroup {
cliente: string;
total: number; // registros
linhas: number; // soma qtdLinhas
totalVivo: number;
totalLine: number;
lucro: number;
}
@Component({
selector: 'app-faturamento',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, HttpClientModule],
templateUrl: './faturamento.html',
styleUrl: './faturamento.scss',
styleUrls: ['./faturamento.scss']
})
export class Faturamento implements OnInit, OnDestroy {
// ===== UI state =====
export class Faturamento implements AfterViewInit, OnDestroy {
toastMessage = '';
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('detailModal', { static: false }) detailModal!: ElementRef<HTMLElement>;
@ViewChild('compareModal', { static: false }) compareModal!: ElementRef<HTMLElement>;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private billing: BillingService,
private cdr: ChangeDetectorRef
) {}
loading = false;
errorMessage: string | null = null;
// ===== filtros =====
tipo: TipoCliente = 'PF';
search = '';
client = '';
page = 1;
pageSize = 20;
// filtros
searchTerm = '';
filterTipo: TipoFiltro = 'ALL';
// ===== ordenação =====
clientsList: string[] = [];
selectedClients: string[] = [];
showClientMenu = false;
clientSearchTerm = '';
// sort/paging
sortBy: BillingSortBy = 'cliente';
sortDir: SortDir = 'asc';
// ===== dados =====
result: PagedResult<BillingItem> = { page: 1, pageSize: 20, total: 0, items: [] };
clients: string[] = [];
// pagina por CLIENTES (grupos)
page = 1;
pageSize = 10;
total = 0; // total de grupos
// ===== subs =====
private sub = new Subscription();
private searchTimer: ReturnType<typeof setTimeout> | null = null;
// agrupamento
clientGroups: BillingClientGroup[] = [];
pagedClientGroups: BillingClientGroup[] = [];
expandedGroup: string | null = null;
groupRows: BillingItem[] = [];
private rowsByClient = new Map<string, BillingItem[]>();
@ViewChild('errorToast', { static: false }) errorToast?: ElementRef<HTMLDivElement>;
// KPIs
loadingKpis = false;
kpiTotalClientes = 0;
kpiTotalLinhas = 0;
kpiTotalVivo = 0;
kpiTotalLine = 0;
kpiLucro = 0;
constructor(private billingService: BillingService) {}
// modals
detailOpen = false;
compareOpen = false;
detailData: BillingItem | null = null;
compareData: BillingItem | null = null;
ngOnInit(): void {
this.loadClients();
this.loadData();
private searchTimer: any = null;
// cache do ALL
private allCache: BillingItem[] = [];
private allCacheAt = 0;
private allCacheTtlMs = 15000;
// --------------------------
// Eventos globais
// --------------------------
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
if (!isPlatformBrowser(this.platformId)) return;
if (this.anyModalOpen()) return;
if (!this.showClientMenu) return;
const target = ev.target as HTMLElement | null;
if (!target) return;
const inside = !!target.closest('.client-filter-wrap');
if (!inside) {
this.showClientMenu = false;
this.cdr.detectChanges();
}
}
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(ev: KeyboardEvent) {
if (!isPlatformBrowser(this.platformId)) return;
if (ev.key === 'Escape') {
if (this.anyModalOpen()) {
ev.preventDefault();
ev.stopPropagation();
this.closeAllModals();
return;
}
if (this.showClientMenu) {
this.showClientMenu = false;
ev.stopPropagation();
this.cdr.detectChanges();
}
}
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.searchTimer) clearTimeout(this.searchTimer);
}
// =========================
// Ações de filtro
// =========================
setTipo(tipo: TipoCliente) {
if (this.tipo === tipo) return;
this.tipo = tipo;
this.page = 1;
this.client = '';
this.loadClients();
this.loadData();
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => {
this.refreshData(true);
});
}
onSearchChange() {
private initAnimations() {
document.documentElement.classList.add('js-animate');
setTimeout(() => {
const items = document.querySelectorAll<HTMLElement>('[data-animate]');
items.forEach((el) => el.classList.add('is-visible'));
}, 100);
}
// --------------------------
// Helpers
// --------------------------
private anyModalOpen(): boolean {
return !!(this.detailOpen || this.compareOpen);
}
closeAllModals() {
this.detailOpen = false;
this.compareOpen = false;
this.detailData = null;
this.compareData = null;
this.cdr.detectChanges();
}
/** ✅ Evita usar Number(...) no template */
hasLucro(item: BillingItem | null): boolean {
const n = Number((item as any)?.lucro ?? 0);
return !Number.isNaN(n) && n !== 0;
}
/** ✅ Lê observação com/sem acento sem quebrar template */
getObservacao(item: BillingItem | null): string {
const anyItem: any = item as any;
const v =
anyItem?.observacao ??
anyItem?.['observação'] ??
anyItem?.OBSERVACAO ??
anyItem?.['OBSERVAÇÃO'];
const s = (v ?? '').toString().trim();
return s ? s : '—';
}
private normalizeText(s: any): string {
return (s ?? '')
.toString()
.trim()
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
if (filtro === 'ALL') return true;
const t = this.normalizeText(itemTipo);
if (filtro === 'PF') return t === 'PF' || t.includes('FISICA');
if (filtro === 'PJ') return t === 'PJ' || t.includes('JURIDICA');
return true;
}
formatMoney(v: any): string {
const n = Number(v);
if (v === null || v === undefined || Number.isNaN(n)) return '—';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n);
}
formatFranquia(v: any): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') {
const s = v.trim();
if (!s) return '—';
if (/[A-Z]/i.test(s)) return s;
const n = Number(s.replace(',', '.'));
if (Number.isNaN(n)) return s;
return `${n.toLocaleString('pt-BR')} GB`;
}
const n = Number(v);
if (Number.isNaN(n)) return '—';
return `${n.toLocaleString('pt-BR')} GB`;
}
// --------------------------
// Filtros / Clientes
// --------------------------
toggleClientMenu() {
this.showClientMenu = !this.showClientMenu;
}
closeClientDropdown() {
this.showClientMenu = false;
}
isClientSelected(client: string): boolean {
return this.selectedClients.includes(client);
}
get filteredClientsList(): string[] {
const base = this.clientsList ?? [];
if (!this.clientSearchTerm) return base;
const s = this.clientSearchTerm.toLowerCase();
return base.filter((c) => (c ?? '').toLowerCase().includes(s));
}
selectClient(client: string | null) {
if (client === null) {
this.selectedClients = [];
} else {
const idx = this.selectedClients.indexOf(client);
if (idx >= 0) this.selectedClients.splice(idx, 1);
else this.selectedClients.push(client);
}
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}
removeClient(client: string, event: Event) {
event.stopPropagation();
const idx = this.selectedClients.indexOf(client);
if (idx >= 0) this.selectedClients.splice(idx, 1);
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}
clearClientSelection(event?: Event) {
if (event) event.stopPropagation();
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}
setFilter(type: 'ALL' | TipoCliente) {
if (this.filterTipo === type) return;
this.filterTipo = type;
this.selectedClients = [];
this.clientSearchTerm = '';
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.refreshData(true);
}
// --------------------------
// Search
// --------------------------
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.loadData();
}, 350);
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}, 250);
}
applyClientFilter() {
clearSearch() {
this.searchTerm = '';
this.page = 1;
this.loadData();
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}
clearClient() {
this.client = '';
this.page = 1;
this.loadData();
// --------------------------
// Data
// --------------------------
refreshData(forceReloadAll = false) {
this.loadAllAndApply(forceReloadAll);
}
refresh() {
this.loadClients();
this.loadData();
private getAllItems(force = false): Promise<BillingItem[]> {
const now = Date.now();
if (!force && this.allCache.length > 0 && (now - this.allCacheAt) < this.allCacheTtlMs) {
return Promise.resolve(this.allCache);
}
changePageSize() {
this.page = 1;
this.loadData();
return new Promise((resolve) => {
this.billing.getAll().subscribe({
next: (items) => {
this.allCache = (items ?? []);
this.allCacheAt = Date.now();
resolve(this.allCache);
},
error: () => resolve(this.allCache ?? [])
});
});
}
// =========================
// Ordenação
// =========================
toggleSort(col: BillingSortBy) {
if (this.sortBy === col) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = col;
private rebuildClientsList(baseTipo: BillingItem[]) {
const set = new Set<string>();
for (const r of baseTipo) {
const c = (r.cliente ?? '').trim();
if (c) set.add(c);
}
this.clientsList = Array.from(set).sort((a, b) => a.localeCompare(b));
}
private buildGroups(items: BillingItem[]) {
this.rowsByClient.clear();
const safeClient = (c: any) => (String(c ?? '').trim() || 'SEM CLIENTE');
for (const r of (items ?? [])) {
const key = safeClient(r.cliente);
(r as any).cliente = key;
const arr = this.rowsByClient.get(key) ?? [];
arr.push(r);
this.rowsByClient.set(key, arr);
}
const groups: BillingClientGroup[] = [];
this.rowsByClient.forEach((arr, cliente) => {
let linhas = 0;
let totalVivo = 0;
let totalLine = 0;
let lucro = 0;
for (const x of arr) {
linhas += Number(x.qtdLinhas ?? 0) || 0;
totalVivo += Number(x.valorContratoVivo ?? 0) || 0;
totalLine += Number(x.valorContratoLine ?? 0) || 0;
lucro += Number((x as any).lucro ?? 0) || 0;
}
groups.push({
cliente,
total: arr.length,
linhas,
totalVivo: Number(totalVivo.toFixed(2)),
totalLine: Number(totalLine.toFixed(2)),
lucro: Number(lucro.toFixed(2))
});
});
groups.sort((a, b) => a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' }));
this.clientGroups = groups;
this.total = groups.length;
this.applyGroupPagination();
}
private applyGroupPagination() {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
this.pagedClientGroups = (this.clientGroups ?? []).slice(start, end);
if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) {
this.expandedGroup = null;
this.groupRows = [];
}
}
private sortRows(arr: BillingItem[]): BillingItem[] {
const dir = this.sortDir === 'asc' ? 1 : -1;
const getVal = (r: BillingItem) => {
switch (this.sortBy) {
case 'tipo': return (r.tipo ?? '').toString();
case 'item': return r.item ?? 0;
case 'cliente': return (r.cliente ?? '').toString();
case 'qtdlinhas': return r.qtdLinhas ?? 0;
case 'franquiavivo': return r.franquiaVivo ?? 0;
case 'valorcontratovivo': return r.valorContratoVivo ?? 0;
case 'franquialine': return r.franquiaLine ?? 0;
case 'valorcontratoline': return r.valorContratoLine ?? 0;
case 'lucro': return (r as any).lucro ?? 0;
case 'aparelho': return (r.aparelho ?? '').toString();
case 'formapagamento': return (r.formaPagamento ?? '').toString();
default: return (r.cliente ?? '').toString();
}
};
return [...(arr ?? [])].sort((a, b) => {
const va = getVal(a);
const vb = getVal(b);
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb), 'pt-BR', { sensitivity: 'base' }) * dir;
});
}
toggleGroup(cliente: string) {
if (this.expandedGroup === cliente) {
this.expandedGroup = null;
this.groupRows = [];
return;
}
this.expandedGroup = cliente;
const rows = this.rowsByClient.get(cliente) ?? [];
this.groupRows = this.sortRows(rows);
this.cdr.detectChanges();
}
private applyClientSide(allItems: BillingItem[]) {
const baseTipo = (allItems ?? []).filter((r) => this.matchesTipo(r.tipo, this.filterTipo));
this.rebuildClientsList(baseTipo);
let arr = [...baseTipo];
if (this.selectedClients.length > 0) {
const set = new Set(this.selectedClients.map((x) => this.normalizeText(x)));
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
}
const term = (this.searchTerm ?? '').trim().toLowerCase();
if (term) {
arr = arr.filter((r) => {
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
const unique = new Set<string>();
let totalLinhas = 0;
let totalVivo = 0;
let totalLine = 0;
let totalLucro = 0;
for (const r of arr) {
const c = (r.cliente ?? '').trim();
if (c) unique.add(c);
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
totalVivo += Number(r.valorContratoVivo ?? 0) || 0;
totalLine += Number(r.valorContratoLine ?? 0) || 0;
totalLucro += Number((r as any).lucro ?? 0) || 0;
}
this.kpiTotalClientes = unique.size;
this.kpiTotalLinhas = totalLinhas;
this.kpiTotalVivo = Number(totalVivo.toFixed(2));
this.kpiTotalLine = Number(totalLine.toFixed(2));
this.kpiLucro = Number(totalLucro.toFixed(2));
this.buildGroups(arr);
}
private async loadAllAndApply(forceReloadAll = false) {
this.loading = true;
this.loadingKpis = true;
this.clientGroups = [];
this.pagedClientGroups = [];
this.rowsByClient.clear();
this.groupRows = [];
try {
const all = await this.getAllItems(forceReloadAll);
this.applyClientSide(all);
} finally {
this.loading = false;
this.loadingKpis = false;
this.cdr.detectChanges();
}
}
// --------------------------
// Sort / Paging
// --------------------------
setSort(key: BillingSortBy) {
if (this.sortBy === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
else {
this.sortBy = key;
this.sortDir = 'asc';
}
if (this.expandedGroup) {
const rows = this.rowsByClient.get(this.expandedGroup) ?? [];
this.groupRows = this.sortRows(rows);
}
this.cdr.detectChanges();
}
onPageSizeChange() {
this.page = 1;
this.loadData();
}
sortIcon(col: BillingSortBy): string {
if (this.sortBy !== col) return 'bi bi-arrow-down-up';
return this.sortDir === 'asc' ? 'bi bi-sort-down' : 'bi bi-sort-up';
}
// =========================
// Paginação
// =========================
get totalPages(): number {
const t = this.result?.total ?? 0;
return Math.max(1, Math.ceil(t / (this.pageSize || 20)));
this.applyGroupPagination();
this.cdr.detectChanges();
}
goToPage(p: number) {
const tp = this.totalPages;
const next = Math.min(Math.max(1, p), tp);
if (next === this.page) return;
this.page = next;
this.loadData();
this.page = Math.max(1, Math.min(this.totalPages, p));
this.applyGroupPagination();
this.cdr.detectChanges();
}
prevPage() {
this.goToPage(this.page - 1);
trackById(_: number, row: BillingItem) {
return row.id;
}
nextPage() {
this.goToPage(this.page + 1);
trackByCliente(_: number, g: BillingClientGroup) {
return g.cliente;
}
// =========================
// Carregamento
// =========================
private loadClients() {
this.sub.add(
this.billingService.getClients(this.tipo).subscribe({
next: (list: string[]) => {
this.clients = list ?? [];
},
error: () => {
this.clients = [];
},
})
);
get totalPages() {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
}
private loadData() {
this.loading = true;
this.errorMessage = null;
this.sub.add(
this.billingService
.getPaged({
tipo: this.tipo,
search: this.search,
client: this.client,
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDir: this.sortDir,
})
.subscribe({
next: (res: PagedResult<BillingItem>) => {
this.result = res ?? { page: this.page, pageSize: this.pageSize, total: 0, items: [] };
this.page = this.result.page ?? this.page;
this.pageSize = this.result.pageSize ?? this.pageSize;
},
error: (err: any) => {
this.errorMessage = err?.error?.message || 'Erro ao carregar faturamento.';
this.result = { page: this.page, pageSize: this.pageSize, total: 0, items: [] };
},
complete: () => {
this.loading = false;
},
})
);
get pageStart() {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
}
// =========================
// Helpers de display
// =========================
brl(v: number | null | undefined): string {
const n = typeof v === 'number' ? v : 0;
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
get pageEnd() {
return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total);
}
num(v: number | null | undefined): string {
const n = typeof v === 'number' ? v : 0;
return n.toLocaleString('pt-BR');
get pageNumbers() {
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;
}
trackById(_: number, it: BillingItem) {
return it.id;
// --------------------------
// Modals
// --------------------------
onDetalhes(r: BillingItem) {
this.detailOpen = true;
this.detailData = r;
this.cdr.detectChanges();
}
onComparativo(r: BillingItem) {
this.compareOpen = true;
this.compareData = r;
this.cdr.detectChanges();
}
}

View File

@ -0,0 +1,90 @@
<section class="parcelamento-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>
<div class="page-header">
<div class="title">
<h2>Parcelamento</h2>
<p>KPIs e análise mensal do parcelamento importado da planilha.</p>
</div>
<div class="filters glass">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-5">
<label class="form-label">Cliente</label>
<select class="form-select" [(ngModel)]="selectedClient">
<option value="">Todos</option>
<option *ngFor="let c of clients" [value]="c">{{ c }}</option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Linha</label>
<input class="form-control" placeholder="Ex: 7199..." [(ngModel)]="lineSearch" />
</div>
<div class="col-12 col-md-3 d-flex gap-2">
<button class="btn btn-primary w-100" (click)="onApplyFilters()" [disabled]="loading">
<i class="bi bi-funnel"></i> Filtrar
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()" [disabled]="loading" title="Limpar">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>
</div>
<!-- KPIs -->
<div class="kpis">
<div class="kpi-card glass">
<span class="kpi-label">Total (c/ desconto)</span>
<span class="kpi-value">{{ money(kpis.totalComDesconto) }}</span>
</div>
<div class="kpi-card glass">
<span class="kpi-label">Total (valor cheio)</span>
<span class="kpi-value">{{ money(kpis.totalValorCheio) }}</span>
</div>
<div class="kpi-card glass">
<span class="kpi-label">Desconto total</span>
<span class="kpi-value">{{ money(kpis.totalDesconto) }}</span>
</div>
<div class="kpi-card glass">
<span class="kpi-label">Linhas / Clientes</span>
<span class="kpi-value">{{ kpis.linhas }} <small>/ {{ kpis.clientes }}</small></span>
<span class="kpi-sub">{{ kpis.meses }} meses mapeados</span>
</div>
</div>
<!-- Charts -->
<div class="charts">
<div class="chart-card glass">
<div class="chart-head">
<h5>Valor por mês</h5>
<span class="muted">Soma mensal do parcelamento</span>
</div>
<div class="chart-area">
<canvas #monthlyCanvas></canvas>
</div>
</div>
<div class="chart-card glass">
<div class="chart-head">
<h5>Top 10 linhas</h5>
<span class="muted">Linhas com maior soma total</span>
</div>
<div class="chart-area">
<canvas #topLinesCanvas></canvas>
</div>
</div>
</div>
<div class="loading" *ngIf="loading">
<div class="spinner-border" role="status"></div>
<span>Carregando...</span>
</div>
</section>

View File

@ -0,0 +1,151 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--radius-xl: 22px;
--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);
}
.parcelamento-page {
position: relative;
padding: clamp(14px, 3vw, 26px);
min-height: calc(100vh - 70px);
color: var(--text);
}
.page-blob {
position: absolute;
border-radius: 999px;
filter: blur(0.2px);
opacity: 0.20;
pointer-events: none;
}
.blob-1 { width: 240px; height: 240px; top: 16px; left: -50px; background: var(--brand); }
.blob-2 { width: 280px; height: 280px; top: 140px; right: -80px; background: var(--blue); opacity: .14; }
.blob-3 { width: 220px; height: 220px; bottom: 30px; left: 20%; background: var(--brand); opacity: .10; }
.glass {
background: var(--glass-bg);
border: var(--glass-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
backdrop-filter: blur(10px);
}
.page-header {
display: grid;
gap: 14px;
margin-bottom: 14px;
.title {
h2 { margin: 0; font-weight: 800; letter-spacing: -0.3px; }
p { margin: 2px 0 0; color: var(--muted); }
}
.filters {
padding: 14px;
.form-label { font-weight: 700; color: rgba(17,18,20,.78); }
}
}
.kpis {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 14px;
@media (max-width: 992px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
.kpi-card {
padding: 14px 16px;
.kpi-label {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: rgba(17,18,20,.70);
}
.kpi-value {
display: block;
font-size: 1.55rem;
font-weight: 900;
margin-top: 6px;
small {
font-size: 1rem;
font-weight: 800;
color: rgba(17,18,20,.70);
}
}
.kpi-sub {
display: block;
margin-top: 6px;
font-size: .86rem;
color: var(--muted);
}
}
}
.charts {
display: grid;
gap: 14px;
grid-template-columns: 1.2fr 1fr;
@media (max-width: 992px) {
grid-template-columns: 1fr;
}
.chart-card {
padding: 14px 16px;
overflow: hidden;
.chart-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
h5 {
margin: 0;
font-weight: 900;
letter-spacing: -0.2px;
}
.muted {
font-size: .9rem;
color: var(--muted);
white-space: nowrap;
}
}
.chart-area {
height: 360px;
@media (max-width: 520px) {
height: 320px;
}
}
}
}
.loading {
margin-top: 14px;
display: flex;
gap: 10px;
align-items: center;
color: var(--muted);
}

View File

@ -0,0 +1,195 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, ElementRef, Inject, PLATFORM_ID, ViewChild, AfterViewInit, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import Chart from 'chart.js/auto';
import {
ParcelamentoService,
ParcelamentoKpis,
ParcelamentoMonthlyPoint,
ParcelamentoTopLine
} from '../../services/parcelamento.service';
@Component({
selector: 'app-parcelamento',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
templateUrl: './parcelamento.html',
styleUrl: './parcelamento.scss'
})
export class Parcelamento implements OnInit, AfterViewInit {
@ViewChild('monthlyCanvas') monthlyCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('topLinesCanvas') topLinesCanvas!: ElementRef<HTMLCanvasElement>;
private monthlyChart?: Chart;
private topLinesChart?: Chart;
loading = false;
clients: string[] = [];
selectedClient: string = '';
lineSearch: string = '';
kpis: ParcelamentoKpis = {
linhas: 0,
clientes: 0,
totalValorCheio: 0,
totalDesconto: 0,
totalComDesconto: 0,
meses: 0
};
monthlySeries: ParcelamentoMonthlyPoint[] = [];
topLines: ParcelamentoTopLine[] = [];
constructor(
private parcelamentoService: ParcelamentoService,
@Inject(PLATFORM_ID) private platformId: Object
) {}
async ngOnInit() {
await this.loadClients();
await this.refreshAll();
}
ngAfterViewInit() {
if (isPlatformBrowser(this.platformId)) {
this.renderCharts();
}
}
private buildOpts() {
const cliente = this.selectedClient?.trim() || undefined;
const linha = this.onlyDigits(this.lineSearch) || undefined;
return { cliente, linha };
}
async loadClients() {
try {
this.clients = await firstValueFrom(this.parcelamentoService.getClients());
} catch {
this.clients = [];
}
}
async refreshAll() {
this.loading = true;
try {
const opts = this.buildOpts();
this.kpis = await firstValueFrom(this.parcelamentoService.getKpis(opts));
this.monthlySeries = await firstValueFrom(this.parcelamentoService.getMonthlySeries(opts));
this.topLines = await firstValueFrom(
this.parcelamentoService.getTopLines({ cliente: opts.cliente, take: 10 })
);
this.renderCharts();
} finally {
this.loading = false;
}
}
onClearFilters() {
this.selectedClient = '';
this.lineSearch = '';
this.refreshAll();
}
onApplyFilters() {
this.refreshAll();
}
private renderCharts() {
if (!isPlatformBrowser(this.platformId)) return;
if (!this.monthlyCanvas || !this.topLinesCanvas) return;
this.renderMonthlyChart();
this.renderTopLinesChart();
}
private renderMonthlyChart() {
const labels = this.monthlySeries.map(x => x.label);
const values = this.monthlySeries.map(x => x.total ?? 0);
if (this.monthlyChart) this.monthlyChart.destroy();
this.monthlyChart = new Chart(this.monthlyCanvas.nativeElement.getContext('2d')!, {
type: 'bar',
data: {
labels,
datasets: [{ label: 'Valor por mês (R$)', data: values }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: {
callbacks: {
label: (ctx) => {
const y = (ctx.parsed as any)?.y;
return ` ${this.money(typeof y === 'number' ? y : 0)}`;
}
}
}
},
scales: {
y: {
ticks: {
callback: (v: any) => this.money(Number(v) || 0)
}
}
}
}
});
}
private renderTopLinesChart() {
const labels = this.topLines.map(x => (x.linha ?? '').toString());
const values = this.topLines.map(x => x.total ?? 0);
if (this.topLinesChart) this.topLinesChart.destroy();
this.topLinesChart = new Chart(this.topLinesCanvas.nativeElement.getContext('2d')!, {
type: 'bar',
data: {
labels,
datasets: [{ label: 'Top 10 linhas (Total R$)', data: values }]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: {
callbacks: {
label: (ctx) => {
const x = (ctx.parsed as any)?.x;
return ` ${this.money(typeof x === 'number' ? x : 0)}`;
}
}
}
},
scales: {
x: {
ticks: {
callback: (v: any) => this.money(Number(v) || 0)
}
}
}
}
});
}
money(v: number) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v ?? 0);
}
private onlyDigits(s: string) {
return (s ?? '').replace(/\D/g, '');
}
}

View File

@ -0,0 +1,351 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-success 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="troca-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-troca">
<div class="troca-card" data-animate>
<div class="troca-header">
<div class="header-row-top">
<div class="title-badge" data-animate>
<i class="bi bi-arrow-left-right"></i> TROCA DE NÚMERO
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">Troca de Número</h5>
<small class="subtitle">Registros importados da aba “TROCA DE NÚMERO”</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Troca
</button>
</div>
</div>
<div class="troca-kpis mt-4 animate-fade-in">
<div class="kpi">
<span class="lbl">Motivos</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ total || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Registros</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Trocas</span>
<span class="val text-brand">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-success">ICCID</span>
<span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span>
</span>
</div>
</div>
<div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
</span>
<input class="form-control" placeholder="Pesquisar (linha, ICCID, motivo, observação)..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i>
</button>
</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">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div>
</div>
</div>
</div>
<div class="troca-body">
<div class="groups-container">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!loading && pagedGroups.length === 0">
Nenhum dado encontrado.
</div>
<div class="group-list" *ngIf="!loading">
<div *ngFor="let g of pagedGroups" class="client-group-card" [class.expanded]="expandedGroup === g.key">
<div class="group-header" (click)="toggleGroup(g.key)">
<div class="group-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.key">{{ g.key }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.total }} Registros</span>
<span class="badge-pill swap" *ngIf="g.trocas > 0">{{ g.trocas }} Trocas</span>
<span class="badge-pill ok" *ngIf="g.comIccid > 0">{{ g.comIccid }} ICCID</span>
<span class="badge-pill warn" *ngIf="g.semIccid > 0">{{ g.semIccid }} Sem ICCID</span>
</div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="group-body" *ngIf="expandedGroup === g.key">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Registros do Motivo</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Use o botão à direita para editar</span>
</div>
<div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA ANTIGA</th>
<th>LINHA NOVA</th>
<th>ICCID</th>
<th>DATA TROCA</th>
<th>OBSERVAÇÃO</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngIf="groupRows.length === 0">
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro.</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item || '-' }}</td>
<td class="text-dark">{{ r.linhaAntiga || '-' }}</td>
<td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
<td class="small font-monospace">{{ r.iccid || '-' }}</td>
<td class="text-muted small fw-bold">{{ displayValue('dataTroca', r.dataTroca) }}</td>
<td class="td-clip" style="max-width: 360px;" [title]="r.observacao">{{ r.observacao || '-' }}</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
<i class="bi bi-pencil-square"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="troca-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} Motivos</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>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<!-- EDIT MODAL -->
<div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Troca de Número
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataTroca" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.motivo" />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="editModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl>
<div class="p-5 text-center text-muted">Preparando edição...</div>
</ng-template>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Troca
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-pencil me-2"></i> Preencha os dados</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Troca</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataTroca" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" />
</div>
<div class="form-field span-2">
<label>Motivo</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.motivo" placeholder="Ex: perda/roubo, troca de colaborador..." />
</div>
<div class="form-field span-2">
<label>Observação</label>
<textarea class="form-control form-control-sm" rows="3" [(ngModel)]="createModel.observacao"></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,614 @@
: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;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--radius-xl: 22px;
--radius-lg: 16px;
--radius-md: 12px;
--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);
--swap-bg: rgba(227, 61, 207, 0.12);
--swap-text: #E33DCF;
--same-bg: rgba(3, 15, 170, 0.08);
--same-text: #030FAA;
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
/* PAGE */
.troca-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-troca {
width: 100%;
max-width: 1180px;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 200px;
}
.troca-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;
max-height: calc(100vh - 80px);
&::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;
}
}
/* HEADER */
.troca-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; }
/* BUTTONS */
.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);
}
&:disabled { opacity: 0.7; cursor: not-allowed; transform: none; }
}
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(3, 15, 170, 0.25);
color: var(--blue);
&:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; }
}
/* KPIS */
.troca-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-brand { color: var(--brand) !important; }
}
.val {
font-size: 1.25rem;
font-weight: 950;
color: var(--text);
&.text-success { color: var(--success-text) !important; }
&.text-brand { color: var(--brand) !important; }
}
}
/* CONTROLS */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.search-group {
max-width: 380px;
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: var(--muted);
padding-left: 14px;
padding-right: 8px;
display: flex;
align-items: center;
i { font-size: 1rem; }
}
.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; }
i { font-size: 1rem; }
}
}
.page-size {
margin-left: auto;
@media (max-width: 500px) { margin-left: 0; width: 100%; justify-content: space-between; }
}
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(17, 18, 20, 0.15);
border-radius: 12px;
color: var(--blue);
font-weight: 800;
font-size: 0.9rem;
text-align: left;
padding: 8px 36px 8px 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover {
background: #fff;
border-color: var(--blue);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1);
}
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
}
}
.select-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--muted);
font-size: 0.75rem;
}
.select-wrapper:hover .select-icon { color: var(--blue); }
/* BODY */
.troca-body { padding: 0; background: transparent; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.groups-container { padding: 16px; overflow-y: auto; height: 100%; }
.group-list { display: flex; flex-direction: column; gap: 12px; }
.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);
}
.client-group-card {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(17,18,20,0.08);
overflow: hidden;
transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227,61,207,0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227,61,207,0.12); }
}
.group-header {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background: linear-gradient(180deg, #fff, #fdfdfd);
&:hover .group-toggle-icon { color: var(--brand); }
}
.group-info { display: flex; flex-direction: column; gap: 6px; }
.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); }
&.swap { background: var(--swap-bg); color: var(--swap-text); }
&.ok { background: var(--success-bg); color: var(--success-text); }
&.warn { background: var(--warn-bg); color: var(--warn-text); }
}
.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); }
.group-body {
border-top: 1px solid rgba(17,18,20,0.06);
background: #fbfbfc;
}
.chip-muted {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 800;
color: rgba(17,18,20,0.55);
padding: 4px 10px;
border-radius: 999px;
background: rgba(17,18,20,0.04);
border: 1px solid rgba(17,18,20,0.06);
}
.inner-table-wrap { max-height: 450px; overflow-y: auto; }
/* TABLE */
.table-wrap { overflow-x: auto; overflow-y: auto; height: 100%; }
.table-modern {
width: 100%;
min-width: 1100px;
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 !important;
}
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 !important;
}
}
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.fw-black { font-weight: 950; }
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 520px; }
.empty-state { background: rgba(255,255,255,0.4); }
/* ACTIONS */
.action-group { display: flex; justify-content: center; gap: 6px; }
.btn-icon {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
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); }
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
}
/* FOOTER */
.troca-footer {
padding: 14px 24px;
border-top: 1px solid rgba(17, 18, 20, 0.06);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
@media (max-width: 768px) { justify-content: center; text-align: center; }
}
.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;
}
/* MODALS */
.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-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;
width: min(900px, 100%);
max-height: 90vh;
}
.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); }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
}
}
.modal-body { padding: 24px; overflow-y: auto; }
.bg-light-gray { background-color: #f8f9fa; }
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
.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; display: flex; flex-direction: column; }
.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
.box-body { padding: 16px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } }
.form-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; @media (max-width: 600px) { grid-column: span 1; } }
}
.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; }
}

View File

@ -0,0 +1,455 @@
import {
Component,
ElementRef,
ViewChild,
Inject,
PLATFORM_ID,
AfterViewInit,
ChangeDetectorRef
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
interface TrocaRow {
id: string;
item: string;
linhaAntiga: string;
linhaNova: string;
iccid: string;
dataTroca: string;
motivo: string;
observacao: string;
raw: any;
}
interface ApiPagedResult<T> {
page?: number;
pageSize?: number;
total?: number;
items?: T[];
}
interface GroupItem {
key: string; // aqui é o MOTIVO
total: number;
trocas: number;
comIccid: number;
semIccid: number;
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
templateUrl: './troca-numero.html',
styleUrls: ['./troca-numero.scss']
})
export class TrocaNumero implements AfterViewInit {
toastMessage = '';
loading = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef
) {}
private readonly apiBase = 'https://localhost:7205/api/trocanumero';
// ====== DATA ======
groups: GroupItem[] = [];
pagedGroups: GroupItem[] = [];
expandedGroup: string | null = null;
groupRows: TrocaRow[] = [];
private rowsByKey = new Map<string, TrocaRow[]>();
// KPIs
groupLoadedRecords = 0;
groupTotalTrocas = 0;
groupTotalIccids = 0;
// ====== FILTERS & PAGINATION ======
searchTerm = '';
private searchTimer: any = null;
page = 1;
pageSize = 10;
total = 0;
// ====== EDIT MODAL ======
editOpen = false;
editSaving = false;
editModel: any = null;
// ====== CREATE MODAL ======
createOpen = false;
createSaving = false;
createModel: any = {
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataTroca: '',
motivo: '',
observacao: ''
};
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => this.refresh());
}
private initAnimations() {
document.documentElement.classList.add('js-animate');
setTimeout(() => {
const items = document.querySelectorAll<HTMLElement>('[data-animate]');
items.forEach((el) => el.classList.add('is-visible'));
}, 100);
}
refresh() {
this.page = 1;
this.loadForGroups();
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.loadForGroups();
}, 300);
}
clearSearch() {
this.searchTerm = '';
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.loadForGroups();
}
onPageSizeChange() {
this.page = 1;
this.applyPagination();
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.applyPagination();
}
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get pageNumbers() {
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() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageEnd() {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
trackById(_: number, row: TrocaRow) { return row.id; }
// =======================================================================
// LOAD LOGIC (igual MUREG: puxa bastante e agrupa no front)
// =======================================================================
private loadForGroups() {
this.loading = true;
const MAX_FETCH = 5000;
let params = new HttpParams()
.set('page', '1')
.set('pageSize', String(MAX_FETCH))
.set('search', (this.searchTerm ?? '').trim())
.set('sortBy', 'motivo')
.set('sortDir', 'asc');
this.http.get<ApiPagedResult<any> | any[]>(this.apiBase, { params }).subscribe({
next: (res: any) => {
const items = Array.isArray(res) ? res : (res.items ?? []);
const normalized = (items ?? []).map((x: any, idx: number) => this.normalizeRow(x, idx));
this.buildGroups(normalized);
this.applyPagination();
this.loading = false;
this.cdr.detectChanges();
},
error: async () => {
this.loading = false;
await this.showToast('Erro ao carregar Troca de Número.');
}
});
}
private buildGroups(all: TrocaRow[]) {
this.rowsByKey.clear();
const safeKey = (v: any) => (String(v ?? '').trim() || 'SEM MOTIVO');
for (const r of all) {
const key = safeKey(r.motivo);
r.motivo = key;
const arr = this.rowsByKey.get(key) ?? [];
arr.push(r);
this.rowsByKey.set(key, arr);
}
const groups: GroupItem[] = [];
let trocasTotal = 0;
let iccidsTotal = 0;
this.rowsByKey.forEach((arr, key) => {
const total = arr.length;
const trocas = arr.filter(x => this.isTroca(x)).length;
const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length;
const semIccid = total - comIccid;
trocasTotal += trocas;
iccidsTotal += comIccid;
groups.push({ key, total, trocas, comIccid, semIccid });
});
groups.sort((a, b) => a.key.localeCompare(b.key, 'pt-BR', { sensitivity: 'base' }));
this.groups = groups;
this.total = groups.length;
this.groupLoadedRecords = all.length;
this.groupTotalTrocas = trocasTotal;
this.groupTotalIccids = iccidsTotal;
}
private applyPagination() {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
this.pagedGroups = this.groups.slice(start, end);
if (this.expandedGroup && !this.pagedGroups.some(g => g.key === this.expandedGroup)) {
this.expandedGroup = null;
this.groupRows = [];
}
}
toggleGroup(key: string) {
if (this.expandedGroup === key) {
this.expandedGroup = null;
this.groupRows = [];
return;
}
this.expandedGroup = key;
const rows = this.rowsByKey.get(key) ?? [];
this.groupRows = [...rows].sort((a, b) => {
const ai = parseInt(String(a.item ?? '0'), 10);
const bi = parseInt(String(b.item ?? '0'), 10);
if (Number.isFinite(ai) && Number.isFinite(bi) && ai !== bi) return ai - bi;
return String(a.linhaNova ?? '').localeCompare(String(b.linhaNova ?? ''), 'pt-BR', { sensitivity: 'base' });
});
}
isTroca(r: TrocaRow): boolean {
const a = String(r.linhaAntiga ?? '').trim();
const b = String(r.linhaNova ?? '').trim();
if (!a || !b) return false;
return a !== b;
}
private normalizeRow(x: any, idx: number): TrocaRow {
const pick = (obj: any, keys: string[]): any => {
for (const k of keys) {
if (obj && obj[k] !== undefined && obj[k] !== null && String(obj[k]).trim() !== '') return obj[k];
}
return '';
};
const item = pick(x, ['item', 'ITEM', 'ITÉM']);
const linhaAntiga = pick(x, ['linhaAntiga', 'linha_antiga', 'LINHA ANTIGA']);
const linhaNova = pick(x, ['linhaNova', 'linha_nova', 'LINHA NOVA']);
const iccid = pick(x, ['iccid', 'ICCID']);
const dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']);
const motivo = pick(x, ['motivo', 'MOTIVO']);
const observacao = pick(x, ['observacao', 'OBSERVAÇÃO', 'OBSERVACAO', 'OBSERVACAO']);
const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`);
return {
id,
item: String(item ?? ''),
linhaAntiga: String(linhaAntiga ?? ''),
linhaNova: String(linhaNova ?? ''),
iccid: String(iccid ?? ''),
dataTroca: String(dataTroca ?? ''),
motivo: String(motivo ?? ''),
observacao: String(observacao ?? ''),
raw: x
};
}
// ====== MODAL EDIÇÃO ======
onEditar(r: TrocaRow) {
this.editOpen = true;
this.editSaving = false;
this.editModel = {
id: r.id,
item: r.item,
linhaAntiga: r.linhaAntiga,
linhaNova: r.linhaNova,
iccid: r.iccid,
motivo: r.motivo,
observacao: r.observacao,
dataTroca: this.isoToDateInput(r.dataTroca)
};
}
closeEdit() {
this.editOpen = false;
this.editModel = null;
this.editSaving = false;
}
saveEdit() {
if (!this.editModel || !this.editModel.id) return;
this.editSaving = true;
const payload = {
item: this.toNumberOrNull(this.editModel.item),
linhaAntiga: this.editModel.linhaAntiga,
linhaNova: this.editModel.linhaNova,
iccid: this.editModel.iccid,
motivo: this.editModel.motivo,
observacao: this.editModel.observacao,
dataTroca: this.dateInputToIso(this.editModel.dataTroca)
};
this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({
next: async () => {
this.editSaving = false;
await this.showToast('Registro atualizado com sucesso!');
this.closeEdit();
const currentGroup = this.expandedGroup;
this.loadForGroups();
if (currentGroup) setTimeout(() => this.expandedGroup = currentGroup, 350);
},
error: async () => {
this.editSaving = false;
await this.showToast('Erro ao salvar edição.');
}
});
}
// ====== MODAL CRIAÇÃO ======
onCreate() {
this.createOpen = true;
this.createSaving = false;
this.createModel = {
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataTroca: '',
motivo: '',
observacao: ''
};
}
closeCreate() {
this.createOpen = false;
}
saveCreate() {
this.createSaving = true;
const payload = {
item: this.toNumberOrNull(this.createModel.item),
linhaAntiga: this.createModel.linhaAntiga,
linhaNova: this.createModel.linhaNova,
iccid: this.createModel.iccid,
motivo: this.createModel.motivo,
observacao: this.createModel.observacao,
dataTroca: this.dateInputToIso(this.createModel.dataTroca)
};
this.http.post(this.apiBase, payload).subscribe({
next: async () => {
this.createSaving = false;
await this.showToast('Troca criada com sucesso!');
this.closeCreate();
this.loadForGroups();
},
error: async () => {
this.createSaving = false;
await this.showToast('Erro ao criar Troca.');
}
});
}
// Helpers
private toNumberOrNull(v: any): number | null {
const n = parseInt(String(v ?? '').trim(), 10);
return Number.isFinite(n) ? n : null;
}
private isoToDateInput(iso: string | null | undefined): string {
if (!iso) return '';
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0, 10);
}
private dateInputToIso(val: string | null | undefined): string | null {
if (!val) return null;
const dt = new Date(val);
if (Number.isNaN(dt.getTime())) return null;
return dt.toISOString();
}
displayValue(key: TrocaKey, v: any): string {
if (v === null || v === undefined || String(v).trim() === '') return '-';
if (key === 'dataTroca') {
const s = String(v).trim();
const d = new Date(s);
if (!Number.isNaN(d.getTime())) return new Intl.DateTimeFormat('pt-BR').format(d);
return s;
}
return String(v);
}
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

@ -0,0 +1,219 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 12000;">
<div class="toast border-0 shadow" [class.show]="toastOpen" [class.text-bg-success]="toastType === 'success'" [class.text-bg-danger]="toastType === 'danger'">
<div class="toast-header border-bottom-0">
<strong class="me-auto">LineGestão</strong>
<button type="button" class="btn-close" (click)="hideToast()"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark fw-bold">{{ toastMessage }}</div>
</div>
</div>
<section class="vigencia-page">
<span class="page-blob blob-1"></span>
<span class="page-blob blob-2"></span>
<span class="page-blob blob-3"></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-calendar2-week"></i> VIGÊNCIA</div>
<div class="header-title">
<h5 class="title">GESTÃO DE VIGÊNCIA</h5>
<small class="subtitle">Controle de contratos e fidelização</small>
</div>
<div class="d-flex gap-2 justify-content-end"></div>
</div>
<div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'">
<div class="kpi">
<span class="lbl">Total Clientes</span>
<span class="val">{{ kpiTotalClientes }}</span>
</div>
<div class="kpi">
<span class="lbl">Total Linhas</span>
<span class="val">{{ kpiTotalLinhas }}</span>
</div>
<div class="kpi">
<span class="lbl text-danger">Total Vencidos</span>
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Valor Total</span>
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
</div>
</div>
<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="position-relative">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" style="position: absolute; left: 14px; top: 10px; color: var(--muted);"></i>
<input class="form-control ps-5" placeholder="Pesquisar cliente..." [(ngModel)]="search" (keyup.enter)="fetch(1)" [disabled]="loading">
<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>
</div>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="font-size: 0.75rem;">Itens por pág:</span>
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="fetch(1)" [disabled]="loading" style="width: 80px;">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
</div>
</div>
</div>
<div class="geral-body">
<div class="groups-container">
<div class="text-center p-5" *ngIf="loading">
<div class="spinner-border text-brand" role="status"></div>
</div>
<div class="empty-state text-center p-5" *ngIf="!loading && groups.length === 0">
Nenhum dado encontrado.
</div>
<div *ngFor="let g of groups" class="client-group-card mb-3" [class.expanded]="expandedGroup === g.cliente">
<div class="group-header" (click)="toggleGroup(g)">
<div class="group-info">
<h6 class="group-title mb-0">{{ g.cliente }}</h6>
<div class="group-badges mt-1">
<span class="badge-pill total">{{ g.linhas }} Registros</span>
<span class="badge-pill danger" *ngIf="g.vencidos > 0">{{ g.vencidos }} Vencidos</span>
<span class="badge-pill ok" *ngIf="g.linhas - g.vencidos > 0">{{ g.linhas - g.vencidos }} Ativos</span>
</div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Linhas do Cliente</small>
<span class="chip-muted">Total: {{ g.total | currency:'BRL' }}</span>
</div>
<div class="text-center p-4" *ngIf="expandedLoading">
<div class="spinner-border spinner-border-sm text-brand"></div>
</div>
<div class="table-wrap inner-table-wrap" *ngIf="!expandedLoading">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CONTA</th>
<th>USUÁRIO</th>
<th>PLANO</th>
<th>EFETIVAÇÃO</th>
<th>VENCIMENTO</th>
<th class="text-end">TOTAL</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of groupRows" class="table-row-item">
<td class="text-muted fw-bold">{{ row.item }}</td>
<td class="fw-black text-blue">{{ row.linha }}</td>
<td class="text-dark small">{{ row.conta || '-' }}</td>
<td class="text-muted small">{{ row.usuario || '-' }}</td>
<td class="text-muted small td-clip" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
<td class="text-muted small fw-bold">
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
</td>
<td class="fw-bold" [class.text-danger]="isVencido(row.dtTerminoFidelizacao)">
{{ row.dtTerminoFidelizacao ? (row.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
</td>
<td class="text-end fw-black text-dark">
{{ (row.total || 0) | currency:'BRL' }}
</td>
<td>
<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>
</div>
</td>
</tr>
<tr *ngIf="groupRows.length === 0">
<td colspan="9" class="text-center py-4 text-muted fw-bold">Nenhuma linha encontrada.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="geral-footer">
<div class="small text-muted fw-bold">Mostrando {{ (page - 1) * pageSize + 1 }}{{ (page * pageSize) > total ? total : (page * pageSize) }} de {{ total }} Clientes</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page <= 1"><button class="page-link" (click)="fetch(page - 1)">Anterior</button></li>
<li class="page-item active"><button class="page-link">{{ page }}</button></li>
<li class="page-item" [class.disabled]="page >= totalPages"><button class="page-link" (click)="fetch(page + 1)">Próxima</button></li>
</ul>
</nav>
</div>
</div>
</div>
</section>
<div class="lg-backdrop" *ngIf="detailsOpen" (click)="closeDetails()"></div>
<div class="lg-modal" *ngIf="detailsOpen">
<div class="lg-modal-card">
<div class="modal-header d-flex justify-content-between align-items-center p-3 border-bottom">
<h6 class="mb-0 fw-bold"><i class="bi bi-card-list me-2 text-brand"></i> Detalhes da Linha</h6>
<button class="btn-close" (click)="closeDetails()"></button>
</div>
<div class="modal-body p-4 bg-light-gray">
<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 class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Linha</small>
<span class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Conta</small>
<span>{{ selectedRow?.conta || '-' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Usuário</small>
<span>{{ selectedRow?.usuario || '-' }}</span>
</div>
<div class="d-flex flex-column span-2" style="grid-column: span 2;">
<small class="text-muted fw-bold text-uppercase">Plano</small>
<span class="p-2 bg-white border rounded">{{ selectedRow?.planoContrato || '-' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Efetivação</small>
<span>{{ selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Término</small>
<span class="text-danger fw-bold">{{ selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy' }}</span>
</div>
<div class="d-flex flex-column span-2 text-end pt-2 border-top">
<small class="text-muted fw-bold text-uppercase">Valor Total</small>
<span class="fw-black text-brand fs-4">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>

View File

@ -0,0 +1,178 @@
/* ========================================================== */
/* VARIÁVEIS E GERAL */
/* ========================================================== */
: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;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--danger-bg: rgba(220, 53, 69, 0.1);
--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.86);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
}
.vigencia-page {
min-height: 100vh;
padding: 0 12px;
display: flex;
justify-content: center;
position: relative;
overflow-y: auto;
/* Blobs de fundo (Estilo Mureg) */
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; }
}
@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: 1280px; position: relative; z-index: 1; margin-top: 40px; margin-bottom: 100px;
}
.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;
}
/* HEADER */
.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: flex; justify-content: space-between; align-items: center; gap: 12px; }
.title-badge {
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); font-size: 13px; font-weight: 800;
i { color: var(--brand); }
}
.header-title { text-align: center; }
.title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; }
.subtitle { color: var(--muted); font-weight: 700; }
/* KPIs */
.mureg-kpis {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
.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;
transition: transform 0.2s;
&:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; }
.lbl { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; color: var(--muted); }
.val { font-size: 1.25rem; font-weight: 950; color: var(--text); }
.text-brand { color: var(--brand) !important; }
}
}
/* Controls */
.search-group {
border-radius: 12px; background: #fff; border: 1px solid rgba(17,18,20,0.15); display: flex; align-items: center;
&:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); }
.form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; &:focus { outline: none; } }
}
.select-glass {
background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(17, 18, 20, 0.15); border-radius: 12px;
color: var(--blue); font-weight: 800;
}
/* BODY E GRUPOS */
.geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.groups-container { padding: 16px; overflow-y: auto; height: 100%; }
.client-group-card {
background: #fff; border-radius: 16px; border: 1px solid rgba(17,18,20,0.08); overflow: hidden; transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227,61,207,0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227,61,207,0.12); }
}
.group-header {
padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; cursor: pointer;
background: linear-gradient(180deg, #fff, #fdfdfd);
&:hover .group-toggle-icon { color: var(--brand); }
}
.group-title { font-weight: 800; color: var(--text); }
.group-badges { display: flex; gap: 8px; }
.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); }
&.danger { background: var(--danger-bg); color: var(--danger-text); }
&.ok { background: var(--success-bg); color: var(--success-text); }
}
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.chip-muted { font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); }
/* TABELA MUREG STYLE */
.inner-table-wrap { max-height: 500px; overflow-y: auto; }
.table-modern {
width: 100%; 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;
}
tbody tr { transition: background 0.2s; &:hover { background-color: rgba(227, 61, 207, 0.05); } }
td { padding: 12px; font-size: 0.875rem; border-bottom: 1px solid rgba(17,18,20,0.04); vertical-align: middle; }
}
.fw-black { font-weight: 950; }
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-icon {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; color: rgba(17,18,20,0.5);
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
&:hover { background: rgba(3,15,170,0.1); color: var(--blue); }
}
/* FOOTER */
.geral-footer {
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-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODAL */
.lg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.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); }
@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }

View File

@ -0,0 +1,44 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing'; // Necessário para simular HTTP
import { of } from 'rxjs'; // Necessário para simular respostas Observables
import { VigenciaComponent } from './vigencia'; // O nome correto da classe é VigenciaComponent
import { VigenciaService } from '../../services/vigencia.service'; // Ajuste o caminho se necessário
describe('VigenciaComponent', () => {
let component: VigenciaComponent;
let fixture: ComponentFixture<VigenciaComponent>;
let vigenciaServiceMock: any;
beforeEach(async () => {
// 1. Criamos um "Dublê" (Mock) do serviço para não chamar o backend real no teste
vigenciaServiceMock = {
getClients: jasmine.createSpy('getClients').and.returnValue(of([])),
getVigencia: jasmine.createSpy('getVigencia').and.returnValue(of({ items: [], total: 0 })),
getGroups: jasmine.createSpy('getGroups').and.returnValue(of({ items: [], total: 0 }))
};
await TestBed.configureTestingModule({
// 2. Importamos o componente (pois é standalone) e o módulo de teste HTTP
imports: [
VigenciaComponent,
HttpClientTestingModule
],
// 3. Injetamos o mock no lugar do serviço real
providers: [
{ provide: VigenciaService, useValue: vigenciaServiceMock }
]
})
.compileComponents();
fixture = TestBed.createComponent(VigenciaComponent);
component = fixture.componentInstance;
// O detectChanges dispara o ngOnInit. Como mockamos o serviço, ele não vai dar erro.
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,217 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service';
type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger';
type ViewMode = 'lines' | 'groups';
@Component({
selector: 'app-vigencia',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'],
})
export class VigenciaComponent implements OnInit {
loading = false;
errorMsg = '';
// Filtros
search = '';
client = '';
clients: string[] = [];
// Paginação
page = 1;
pageSize = 10;
total = 0;
// Ordenação
sortBy = 'cliente';
sortDir: SortDir = 'asc';
// PADRÃO: GROUPS
viewMode: ViewMode = 'groups';
// Dados
groups: VigenciaClientGroup[] = [];
rows: VigenciaRow[] = [];
// === KPIs GERAIS (Vindos do Backend) ===
kpiTotalClientes = 0;
kpiTotalLinhas = 0;
kpiTotalVencidos = 0;
kpiValorTotal = 0;
// === ACORDEÃO ===
expandedGroup: string | null = null;
expandedLoading = false;
groupRows: VigenciaRow[] = [];
// UI
detailsOpen = false;
selectedRow: VigenciaRow | null = null;
toastOpen = false;
toastMessage = '';
toastType: ToastType = 'success';
private toastTimer: any = null;
constructor(private vigenciaService: VigenciaService) {}
ngOnInit(): void {
this.loadClients();
this.fetch(1);
}
setView(mode: ViewMode): void {
if (this.viewMode === mode) return;
this.viewMode = mode;
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.sortBy = mode === 'groups' ? 'cliente' : 'item';
this.fetch(1);
}
loadClients(): void {
this.vigenciaService.getClients().subscribe({
next: (list) => (this.clients = list ?? []),
error: () => (this.clients = []),
});
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
}
fetch(goToPage?: number): void {
if (goToPage) this.page = goToPage;
this.loading = true;
this.errorMsg = '';
if(goToPage && goToPage !== this.page) this.expandedGroup = null;
if (this.viewMode === 'groups') {
this.fetchGroups();
} else {
this.fetchLines();
}
}
private fetchGroups() {
this.vigenciaService.getGroups({
search: this.search?.trim(),
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDir: this.sortDir,
}).subscribe({
next: (res) => {
// ✅ Preenche Lista
this.groups = res.data.items || [];
this.total = res.data.total || 0;
// ✅ Preenche KPIs Globais
this.kpiTotalClientes = res.kpis.totalClientes;
this.kpiTotalLinhas = res.kpis.totalLinhas;
this.kpiTotalVencidos = res.kpis.totalVencidos;
this.kpiValorTotal = res.kpis.valorTotal;
this.loading = false;
},
error: (err) => this.handleError(err, 'Erro ao carregar clientes.'),
});
}
private fetchLines() {
this.vigenciaService.getVigencia({
search: this.search?.trim(),
client: this.client?.trim(),
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDir: this.sortDir,
}).subscribe({
next: (res) => {
this.rows = res.items || [];
this.total = res.total || 0;
this.loading = false;
},
error: (err) => this.handleError(err, 'Erro ao carregar linhas.'),
});
}
toggleGroup(g: VigenciaClientGroup): void {
if (this.expandedGroup === g.cliente) {
this.expandedGroup = null;
this.groupRows = [];
return;
}
this.expandedGroup = g.cliente;
this.expandedLoading = true;
this.groupRows = [];
this.vigenciaService.getVigencia({
client: g.cliente,
page: 1,
pageSize: 200,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res) => {
this.groupRows = res.items || [];
this.expandedLoading = false;
},
error: () => {
this.showToast('Erro ao carregar detalhes do cliente.', 'danger');
this.expandedLoading = false;
}
});
}
public isVencido(dateValue: any): boolean {
if(!dateValue) return false;
const d = this.parseAnyDate(dateValue);
if(!d) return false;
return this.startOfDay(d) < this.startOfDay(new Date());
}
public isAtivo(dateValue: any): boolean {
if(!dateValue) return true;
const d = this.parseAnyDate(dateValue);
if(!d) return true;
return this.startOfDay(d) >= this.startOfDay(new Date());
}
public parseAnyDate(value: any): Date | null {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
public startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
clearFilters() { this.search = ''; this.fetch(1); }
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; }
handleError(err: HttpErrorResponse, msg: string) {
this.loading = false;
this.expandedLoading = false;
this.errorMsg = (err.error as any)?.message || msg;
this.showToast(msg, 'danger');
}
showToast(msg: string, type: ToastType) {
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
if(this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => this.toastOpen = false, 3000);
}
hideToast() { this.toastOpen = false; }
}

View File

@ -1,13 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Billing } from './billing';
import { BillingService } from './billing';
describe('Billing', () => {
let service: Billing;
describe('BillingService', () => {
let service: BillingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Billing);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BillingService]
});
service = TestBed.inject(BillingService);
});
it('should be created', () => {

View File

@ -1,91 +1,87 @@
// src/app/services/billing.ts
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
export type TipoFiltro = 'ALL' | TipoCliente;
export type BillingSortBy =
| 'tipo'
| 'item'
| 'cliente'
| 'qtdlinhas'
| 'lucro'
| 'valorcontratovivo'
| 'valorcontratoline'
| 'franquiavivo'
| 'franquialine';
| 'valorcontratovivo'
| 'franquialine'
| 'valorcontratoline'
| 'lucro'
| 'aparelho'
| 'formapagamento';
export interface PagedResult<T> {
export interface BillingItem {
id: string;
tipo: string;
item: number;
cliente: string;
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 {
tipo: TipoFiltro;
page: number;
pageSize: number;
sortBy: BillingSortBy;
sortDir: SortDir;
search?: string;
client?: string;
}
export interface ApiPagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface BillingItem {
id: string;
tipo: TipoCliente;
item: number;
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 {
tipo: TipoCliente;
search?: string;
client?: string;
page: number;
pageSize: number;
sortBy?: BillingSortBy;
sortDir?: SortDir;
}
@Injectable({ providedIn: 'root' })
export class BillingService {
// ✅ Use HTTPS pra evitar redirect no preflight (CORS)
// Mesmas portas que você mostrou no log:
// https://localhost:7205
// http://localhost:5298
private baseUrl = 'https://localhost:7205/api/billing';
private readonly baseUrl = 'https://localhost:7205/api/billing';
constructor(private http: HttpClient) {}
getPaged(q: BillingQuery): Observable<PagedResult<BillingItem>> {
const sortBy: BillingSortBy = (q.sortBy ?? 'cliente');
const sortDir: SortDir = (q.sortDir ?? 'asc');
getPaged(q: BillingQuery): Observable<ApiPagedResult<BillingItem>> {
let params = new HttpParams()
.set('tipo', q.tipo)
.set('page', String(q.page))
.set('pageSize', String(q.pageSize))
.set('sortBy', sortBy)
.set('sortDir', sortDir);
.set('sortBy', q.sortBy)
.set('sortDir', q.sortDir);
const search = (q.search ?? '').trim();
if (search) params = params.set('search', search);
if (q.tipo && q.tipo !== 'ALL') params = params.set('tipo', q.tipo);
if (q.search) params = params.set('search', q.search);
if (q.client) params = params.set('client', q.client);
const client = (q.client ?? '').trim();
if (client) params = params.set('client', client);
return this.http.get<PagedResult<BillingItem>>(this.baseUrl, { params });
return this.http.get<ApiPagedResult<BillingItem>>(this.baseUrl, { params });
}
getClients(tipo: TipoCliente): Observable<string[]> {
const params = new HttpParams().set('tipo', tipo);
return this.http.get<string[]>(`${this.baseUrl}/clients`, { params });
getAll(): Observable<BillingItem[]> {
const q: BillingQuery = {
tipo: 'ALL',
page: 1,
pageSize: 99999,
sortBy: 'cliente',
sortDir: 'asc'
};
return this.getPaged(q).pipe(map((res) => res.items ?? []));
}
}

View File

@ -0,0 +1,102 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type SortDir = 'asc' | 'desc';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface UserDataRow {
id: string;
item: number;
linha: string | null;
cliente: string | null;
cpf: string | null;
email: string | null;
celular: string | null;
rg: string | null;
endereco: string | null;
telefoneFixo: string | null;
dataNascimento: string | null;
}
export interface UserDataClientGroup {
cliente: string;
totalRegistros: number;
comCpf: number;
comEmail: number;
}
export interface UserDataKpis {
totalRegistros: number;
clientesUnicos: number;
comCpf: number;
comEmail: number;
}
export interface UserDataGroupResponse {
data: PagedResult<UserDataClientGroup>;
kpis: UserDataKpis;
}
@Injectable({ providedIn: 'root' })
export class DadosUsuariosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getGroups(opts: {
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
}): Observable<UserDataGroupResponse> {
let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search);
params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 10));
params = params.set('sortBy', opts.sortBy || 'cliente');
params = params.set('sortDir', opts.sortDir || 'asc');
return this.http.get<UserDataGroupResponse>(`${this.baseApi}/user-data/groups`, { params });
}
getRows(opts: {
search?: string;
client?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
}): Observable<PagedResult<UserDataRow>> {
let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search);
if (opts.client) params = params.set('client', opts.client);
params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 20));
params = params.set('sortBy', opts.sortBy || 'item');
params = params.set('sortDir', opts.sortDir || 'asc');
return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params });
}
getClients(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`);
}
getById(id: string): Observable<UserDataRow> {
return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`);
}
}

View File

@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ParcelamentoKpis {
linhas: number;
clientes: number;
totalValorCheio: number;
totalDesconto: number;
totalComDesconto: number;
meses: number;
}
export interface ParcelamentoMonthlyPoint {
month: string; // ex: "2026-01"
label: string; // ex: "JAN/2026"
total: number;
}
export interface ParcelamentoTopLine {
linha: string | null;
cliente: string | null;
total: number;
}
@Injectable({ providedIn: 'root' })
export class ParcelamentoService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
// ✅ igual ao seu VigenciaService
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
// ✅ /api/parcelamento/clients
getClients(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/parcelamento/clients`);
}
// ✅ /api/parcelamento/kpis?cliente=...&linha=...
getKpis(opts?: { cliente?: string; linha?: string }): Observable<ParcelamentoKpis> {
let params = new HttpParams();
if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim());
if (opts?.linha && opts.linha.trim()) params = params.set('linha', opts.linha.trim());
return this.http.get<ParcelamentoKpis>(`${this.baseApi}/parcelamento/kpis`, { params });
}
// ✅ /api/parcelamento/series/monthly?cliente=...&linha=...
getMonthlySeries(opts?: { cliente?: string; linha?: string }): Observable<ParcelamentoMonthlyPoint[]> {
let params = new HttpParams();
if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim());
if (opts?.linha && opts.linha.trim()) params = params.set('linha', opts.linha.trim());
return this.http.get<ParcelamentoMonthlyPoint[]>(`${this.baseApi}/parcelamento/series/monthly`, { params });
}
// ✅ /api/parcelamento/top-lines?cliente=...&take=10
getTopLines(opts?: { cliente?: string; take?: number }): Observable<ParcelamentoTopLine[]> {
let params = new HttpParams();
params = params.set('take', String(opts?.take ?? 10));
if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim());
return this.http.get<ParcelamentoTopLine[]>(`${this.baseApi}/parcelamento/top-lines`, { params });
}
}

View File

@ -0,0 +1,89 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type SortDir = 'asc' | 'desc';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface VigenciaRow {
id: string;
item: number;
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 VigenciaClientGroup {
cliente: string;
linhas: number;
total: number;
vencidos: number;
aVencer30: number;
proximoVencimento: string | null;
ultimoVencimento: string | null;
}
// ✅ NOVAS INTERFACES DE RESPOSTA
export interface VigenciaKpis {
totalClientes: number;
totalLinhas: number;
totalVencidos: number;
valorTotal: number;
}
export interface VigenciaGroupResponse {
data: PagedResult<VigenciaClientGroup>;
kpis: VigenciaKpis;
}
@Injectable({ providedIn: 'root' })
export class VigenciaService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getVigencia(opts: { search?: string; client?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable<PagedResult<VigenciaRow>> {
let params = new HttpParams();
if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim());
if (opts.client && opts.client.trim()) params = params.set('client', opts.client.trim());
params = params.set('page', String(opts.page ?? 1));
params = params.set('pageSize', String(opts.pageSize ?? 20));
params = params.set('sortBy', (opts.sortBy ?? 'item').trim());
params = params.set('sortDir', opts.sortDir ?? 'asc');
return this.http.get<PagedResult<VigenciaRow>>(`${this.baseApi}/lines/vigencia`, { params });
}
// ✅ Retorna o objeto composto (Dados + KPIs)
getGroups(opts: { search?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable<VigenciaGroupResponse> {
let params = new HttpParams();
if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim());
params = params.set('page', String(opts.page ?? 1));
params = params.set('pageSize', String(opts.pageSize ?? 20));
params = params.set('sortBy', (opts.sortBy ?? 'cliente').trim());
params = params.set('sortDir', opts.sortDir ?? 'asc');
return this.http.get<VigenciaGroupResponse>(`${this.baseApi}/lines/vigencia/groups`, { params });
}
getClients(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/lines/vigencia/clients`);
}
}