Compare commits
2 Commits
20590bef57
...
d87825b370
| Author | SHA1 | Date |
|---|---|---|
|
|
d87825b370 | |
|
|
03cdf82cb7 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: '' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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; } }
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
</div>
|
||||
|
||||
<!-- CLIENTE MULTI-SELECT -->
|
||||
<div class="client-filter-wrap" (click)="$event.stopPropagation()">
|
||||
<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>
|
||||
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>
|
||||
|
||||
<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"
|
||||
[class.bi-search]="!loading"
|
||||
[class.bi-hourglass-split]="loading"
|
||||
[class.text-brand]="loading"></i>
|
||||
<i class="bi"
|
||||
[class.bi-search]="!loading"
|
||||
[class.bi-hourglass-split]="loading"
|
||||
[class.text-brand]="loading"></i>
|
||||
</span>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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> 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 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 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 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)="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)="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)="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="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>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</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 }}
|
||||
</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>
|
||||
|
||||
<div class="table-wrap table-wrap-tall">
|
||||
<table class="table table-modern align-middle text-center mb-0 billing-table">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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.
|
||||
</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 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -62,12 +73,12 @@
|
|||
}
|
||||
|
||||
@keyframes floaty {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(18px, 10px) scale(1.03); }
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(18px, 10px) scale(1.03); }
|
||||
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);
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.billing.getAll().subscribe({
|
||||
next: (items) => {
|
||||
this.allCache = (items ?? []);
|
||||
this.allCacheAt = Date.now();
|
||||
resolve(this.allCache);
|
||||
},
|
||||
error: () => resolve(this.allCache ?? [])
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
changePageSize() {
|
||||
this.page = 1;
|
||||
this.loadData();
|
||||
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));
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Ordenação
|
||||
// =========================
|
||||
toggleSort(col: BillingSortBy) {
|
||||
if (this.sortBy === col) {
|
||||
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortBy = col;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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, '');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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); } }
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 ?? []));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue