Compare commits
3 Commits
a7cb6d5d95
...
77973fc516
| Author | SHA1 | Date |
|---|---|---|
|
|
77973fc516 | |
|
|
3437b2223e | |
|
|
43bf611122 |
|
|
@ -51,8 +51,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "20kB",
|
"maximumWarning": "35kB",
|
||||||
"maximumError": "40kB"
|
"maximumError": "60kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,7 @@
|
||||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
|
|
@ -6944,6 +6945,7 @@
|
||||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cli-truncate": "^4.0.0",
|
"cli-truncate": "^4.0.0",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
|
|
@ -9576,6 +9578,7 @@
|
||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { HistoricoLinhas } from './pages/historico-linhas/historico-linhas';
|
||||||
import { Perfil } from './pages/perfil/perfil';
|
import { Perfil } from './pages/perfil/perfil';
|
||||||
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
|
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
|
||||||
import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
|
import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
|
||||||
|
import { MveAuditoriaPage } from './pages/mve-auditoria/mve-auditoria';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: Home },
|
{ path: '', component: Home },
|
||||||
|
|
@ -43,6 +44,7 @@ export const routes: Routes = [
|
||||||
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
|
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
|
||||||
{ path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' },
|
{ path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' },
|
||||||
{ path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' },
|
{ path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' },
|
||||||
|
{ path: 'auditoria-mve', component: MveAuditoriaPage, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Auditoria MVE' },
|
||||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||||
{
|
{
|
||||||
path: 'system/fornecer-usuario',
|
path: 'system/fornecer-usuario',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export class AppComponent {
|
||||||
// ✅ rotas internas (LOGADO) que devem esconder footer
|
// ✅ rotas internas (LOGADO) que devem esconder footer
|
||||||
private readonly loggedPrefixes = [
|
private readonly loggedPrefixes = [
|
||||||
'/geral',
|
'/geral',
|
||||||
|
'/auditoria-mve',
|
||||||
'/solicitacoes-linhas',
|
'/solicitacoes-linhas',
|
||||||
'/mureg',
|
'/mureg',
|
||||||
'/faturamento',
|
'/faturamento',
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #publicHeader>
|
<ng-template #publicHeader>
|
||||||
|
<div class="public-header-layout">
|
||||||
<a routerLink="/" class="logo-area">
|
<a routerLink="/" class="logo-area">
|
||||||
<img src="linegestao-logo.png" alt="Line Gestão" class="logo-symbol" />
|
<img src="linegestao-logo.png" alt="Line Gestão" class="logo-symbol" />
|
||||||
<div class="lg-wordmark" aria-label="Line Gestão">
|
<div class="lg-wordmark" aria-label="Line Gestão">
|
||||||
|
|
@ -228,11 +229,12 @@
|
||||||
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
|
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
|
||||||
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
|
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions">
|
<div class="header-actions public-header-actions">
|
||||||
<a routerLink="/login" class="btn-login-header">
|
<a routerLink="/login" class="btn-login-header">
|
||||||
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
|
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -526,7 +528,7 @@
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
|
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-menu-body">
|
<div class="side-menu-body custom-scroll">
|
||||||
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -536,6 +538,9 @@
|
||||||
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-sim"></i> <span>Geral</span>
|
<i class="bi bi-sim"></i> <span>Geral</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewMveAudit" routerLink="/auditoria-mve" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
|
<i class="bi bi-shield-check"></i> <span>Auditoria MVE</span>
|
||||||
|
</a>
|
||||||
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,27 @@ $logo-secondary-grey: #757575;
|
||||||
display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s;
|
display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s;
|
||||||
&:hover { color: $primary; }
|
&:hover { color: $primary; }
|
||||||
}
|
}
|
||||||
|
.public-header-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-header-layout > .logo-area {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-header-layout > .nav-links {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; }
|
.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; }
|
||||||
.btn-login-header {
|
.btn-login-header {
|
||||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px;
|
||||||
|
|
@ -919,7 +940,17 @@ $logo-secondary-grey: #757575;
|
||||||
.side-wordmark__movel {
|
.side-wordmark__movel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.side-menu-body { padding: 16px; display: flex; flex-direction: column; gap: 4px; }
|
.side-menu-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
.side-item {
|
.side-item {
|
||||||
padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px;
|
padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px;
|
||||||
&:hover { background: $bg-light; }
|
&:hover { background: $bg-light; }
|
||||||
|
|
@ -1072,28 +1103,35 @@ $logo-secondary-grey: #757575;
|
||||||
--scale: 0.21;
|
--scale: 0.21;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-header-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */
|
/* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */
|
||||||
.header-inner > .logo-area {
|
.public-header-layout > .logo-area {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .logo-area .lg-wordmark {
|
.public-header-layout > .logo-area .lg-wordmark {
|
||||||
display: block;
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .logo-area .lg-wordmark {
|
.public-header-layout > .logo-area .lg-wordmark {
|
||||||
--scale: 0.19;
|
--scale: 0.19;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .header-actions {
|
.public-header-layout > .header-actions {
|
||||||
margin-left: auto;
|
margin-left: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .header-actions .btn-login-header {
|
.public-header-layout > .header-actions .btn-login-header {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -1404,20 +1442,20 @@ $logo-secondary-grey: #757575;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.header-inner > .logo-area {
|
.public-header-layout > .logo-area {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .logo-area .logo-symbol {
|
.public-header-layout > .logo-area .logo-symbol {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .logo-area .lg-wordmark {
|
.public-header-layout > .logo-area .lg-wordmark {
|
||||||
--scale: 0.18;
|
--scale: 0.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner > .header-actions .btn-login-header {
|
.public-header-layout > .header-actions .btn-login-header {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
isFinanceiro = false;
|
isFinanceiro = false;
|
||||||
canViewAll = false;
|
canViewAll = false;
|
||||||
canViewFinancialPages = false;
|
canViewFinancialPages = false;
|
||||||
|
canViewMveAudit = false;
|
||||||
clientTenantDisplayName = '';
|
clientTenantDisplayName = '';
|
||||||
private clientTenantNameTenantId: string | null = null;
|
private clientTenantNameTenantId: string | null = null;
|
||||||
private readonly baseApi: string;
|
private readonly baseApi: string;
|
||||||
|
|
@ -100,6 +101,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
'/historico',
|
'/historico',
|
||||||
'/historico-linhas',
|
'/historico-linhas',
|
||||||
'/solicitacoes',
|
'/solicitacoes',
|
||||||
|
'/auditoria-mve',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
'/system',
|
'/system',
|
||||||
];
|
];
|
||||||
|
|
@ -235,6 +237,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
this.isFinanceiro = isFinanceiro;
|
this.isFinanceiro = isFinanceiro;
|
||||||
this.canViewAll = isSysAdmin || isGestor || isFinanceiro;
|
this.canViewAll = isSysAdmin || isGestor || isFinanceiro;
|
||||||
this.canViewFinancialPages = isSysAdmin || isFinanceiro;
|
this.canViewFinancialPages = isSysAdmin || isFinanceiro;
|
||||||
|
this.canViewMveAudit = isSysAdmin || isGestor;
|
||||||
|
|
||||||
if (!this.isClientHeader) {
|
if (!this.isClientHeader) {
|
||||||
this.clientTenantDisplayName = '';
|
this.clientTenantDisplayName = '';
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@
|
||||||
[disabled]="!createModel.contaEmpresa"
|
[disabled]="!createModel.contaEmpresa"
|
||||||
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
|
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
|
||||||
[(ngModel)]="createModel.conta"
|
[(ngModel)]="createModel.conta"
|
||||||
|
(ngModelChange)="onContaChange(false)"
|
||||||
></app-select>
|
></app-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -736,7 +737,7 @@
|
||||||
[disabled]="!activeLine.contaEmpresa"
|
[disabled]="!activeLine.contaEmpresa"
|
||||||
[placeholder]="activeLine.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
|
[placeholder]="activeLine.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
|
||||||
[(ngModel)]="activeLine.conta"
|
[(ngModel)]="activeLine.conta"
|
||||||
(ngModelChange)="onBatchLineDetailsChange()"
|
(ngModelChange)="onBatchContaChange(activeLine)"
|
||||||
></app-select>
|
></app-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1389,6 +1390,10 @@
|
||||||
<span class="lbl">Plano Contratado</span>
|
<span class="lbl">Plano Contratado</span>
|
||||||
<span class="val fw-bold text-brand">{{ detailData.planoContrato || '-' }}</span>
|
<span class="val fw-bold text-brand">{{ detailData.planoContrato || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="lbl">Empresa (Conta)</span>
|
||||||
|
<span class="val">{{ detailData.contaEmpresa || '-' }}</span>
|
||||||
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="lbl">Conta</span>
|
<span class="lbl">Conta</span>
|
||||||
<span class="val">{{ detailData.conta || '-' }}</span>
|
<span class="val">{{ detailData.conta || '-' }}</span>
|
||||||
|
|
@ -1616,8 +1621,8 @@
|
||||||
<div class="box-body">
|
<div class="box-body">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field"><label>Item</label><input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" disabled title="O ID não pode ser alterado" /></div>
|
<div class="form-field"><label>Item</label><input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" disabled title="O ID não pode ser alterado" /></div>
|
||||||
<div class="form-field"><label>Empresa (Conta)</label><app-select class="form-select" size="sm" [options]="contaEmpresaOptionsForEdit" [(ngModel)]="editModel.contaEmpresa" (ngModelChange)="onContaEmpresaChange(true)"></app-select></div>
|
<div class="form-field"><label>Empresa (Conta) <span class="text-danger">*</span></label><app-select class="form-select" size="sm" [options]="contaEmpresaOptionsForEdit" [(ngModel)]="editModel.contaEmpresa" (ngModelChange)="onContaEmpresaChange(true)"></app-select></div>
|
||||||
<div class="form-field"><label>Conta</label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta"></app-select></div>
|
<div class="form-field"><label>Conta <span class="text-danger">*</span></label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta" (ngModelChange)="onContaChange(true)"></app-select></div>
|
||||||
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
|
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
|
||||||
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div>
|
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div>
|
||||||
<div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div>
|
<div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="operadora-filter-row fade-in-up" [style.animation-delay]="'80ms'" *ngIf="!isCliente">
|
||||||
|
<div class="operadora-filter-label">
|
||||||
|
<i class="bi bi-funnel-fill"></i>
|
||||||
|
<span>Filtro de Operadora</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="filter-tab"
|
||||||
|
*ngFor="let option of operadoraFilters; trackBy: trackByOperadoraFilter"
|
||||||
|
[class.active]="operadoraFilter === option.value"
|
||||||
|
[disabled]="operadoraFilterLoading"
|
||||||
|
(click)="onOperadoraFilterChange(option.value)">
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'" *ngIf="!isCliente || clientOverview.hasData">
|
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'" *ngIf="!isCliente || clientOverview.hasData">
|
||||||
<div
|
<div
|
||||||
class="hero-card"
|
class="hero-card"
|
||||||
|
|
@ -44,7 +62,6 @@
|
||||||
<div class="hero-data">
|
<div class="hero-data">
|
||||||
<span class="hero-label">{{ k.title }}</span>
|
<span class="hero-label">{{ k.title }}</span>
|
||||||
<span class="hero-value">{{ k.value }}</span>
|
<span class="hero-value">{{ k.value }}</span>
|
||||||
<span class="hero-hint" *ngIf="k.hint">{{ k.hint }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,7 +83,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body-split">
|
<div class="card-body-split">
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('status')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'status')">
|
||||||
<canvas #chartStatusPie></canvas>
|
<canvas #chartStatusPie></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-list">
|
<div class="status-list">
|
||||||
|
|
@ -107,7 +129,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body-adicionais">
|
<div class="card-body-adicionais">
|
||||||
<div class="chart-wrapper-pie-sm">
|
<div
|
||||||
|
class="chart-wrapper-pie-sm chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('adicionaisComparativo')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'adicionaisComparativo')">
|
||||||
<canvas #chartAdicionaisComparativo></canvas>
|
<canvas #chartAdicionaisComparativo></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="compare-list">
|
<div class="compare-list">
|
||||||
|
|
@ -143,7 +170,12 @@
|
||||||
<p>Status de vencimento atual</p>
|
<p>Status de vencimento atual</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('vigenciaBuckets')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'vigenciaBuckets')">
|
||||||
<canvas #chartVigenciaSupervisao></canvas>
|
<canvas #chartVigenciaSupervisao></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,7 +188,12 @@
|
||||||
<p>Linhas com e sem serviço ativo</p>
|
<p>Linhas com e sem serviço ativo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('travel')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'travel')">
|
||||||
<canvas #chartTravelMundo></canvas>
|
<canvas #chartTravelMundo></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +207,12 @@
|
||||||
<p>Distribuição da base por faixa de franquia</p>
|
<p>Distribuição da base por faixa de franquia</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact">
|
<div
|
||||||
|
class="chart-wrapper-bar compact chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('linhasFranquia')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'linhasFranquia')">
|
||||||
<canvas #chartLinhasPorFranquia></canvas>
|
<canvas #chartLinhasPorFranquia></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +224,12 @@
|
||||||
<p>Quantidade de linhas por serviço adicional ativo</p>
|
<p>Quantidade de linhas por serviço adicional ativo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact">
|
<div
|
||||||
|
class="chart-wrapper-bar compact chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('adicionaisPagos')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'adicionaisPagos')">
|
||||||
<canvas #chartAdicionaisPagos></canvas>
|
<canvas #chartAdicionaisPagos></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,13 +241,65 @@
|
||||||
<p>Quantidade de linhas e-SIM e SIMCARD</p>
|
<p>Quantidade de linhas e-SIM e SIMCARD</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('tipoChip')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'tipoChip')">
|
||||||
<canvas #chartTipoChip></canvas>
|
<canvas #chartTipoChip></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-section fade-in-up" [style.animation-delay]="'300ms'" *ngIf="showVivoComparison">
|
||||||
|
<div class="context-title">
|
||||||
|
<h2>Comparativo VIVO</h2>
|
||||||
|
<p>Comparação entre contas da operadora VIVO: MACROPHONY x LINE MÓVEL.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vivo-comparison-grid">
|
||||||
|
<div class="card-modern">
|
||||||
|
<div class="card-header-clean">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Linhas por Empresa</h3>
|
||||||
|
<p>Volume total de linhas VIVO por empresa.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('vivoEmpresasLinhas')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'vivoEmpresasLinhas')">
|
||||||
|
<canvas #chartVivoEmpresasLinhas></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-modern">
|
||||||
|
<div class="card-header-clean">
|
||||||
|
<div class="header-text">
|
||||||
|
<h3>Adicionais por Empresa</h3>
|
||||||
|
<p>Comparação de linhas com e sem adicionais pagos.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('vivoEmpresasAdicionais')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'vivoEmpresasAdicionais')">
|
||||||
|
<canvas #chartVivoEmpresasAdicionais></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vivo-comparison-empty" *ngIf="vivoComparison.macrophonyLinhas === 0 && vivoComparison.lineMovelLinhas === 0">
|
||||||
|
Não há linhas VIVO vinculadas às empresas MACROPHONY ou LINE MÓVEL para o filtro atual.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
|
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
|
||||||
<h2>Página Resumo</h2>
|
<h2>Página Resumo</h2>
|
||||||
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
|
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
|
||||||
|
|
@ -247,22 +346,50 @@
|
||||||
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
|
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>Top Clientes (Qtd. Linhas)</h6>
|
<h6>Top Clientes (Qtd. Linhas)</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoTopClientes></canvas></div>
|
<div
|
||||||
|
class="chart-area chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoTopClientes')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoTopClientes')">
|
||||||
|
<canvas #chartResumoTopClientes></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>Top Planos (Qtd. Linhas)</h6>
|
<h6>Top Planos (Qtd. Linhas)</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
|
<div
|
||||||
|
class="chart-area chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoTopPlanos')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoTopPlanos')">
|
||||||
|
<canvas #chartResumoTopPlanos></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>PF vs PJ (Qtd. Linhas)</h6>
|
<h6>PF vs PJ (Qtd. Linhas)</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
|
<div
|
||||||
|
class="chart-area chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoPfPj')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoPfPj')">
|
||||||
|
<canvas #chartResumoPfPjLinhas></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card">
|
<div class="mini-chart-card">
|
||||||
<h6>Reserva por DDD</h6>
|
<h6>Reserva por DDD</h6>
|
||||||
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
|
<div
|
||||||
|
class="chart-area chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoReservaDdd')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoReservaDdd')">
|
||||||
|
<canvas #chartResumoReservaDdd></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mini-chart-card mini-metric-card">
|
<div class="mini-chart-card mini-metric-card">
|
||||||
|
|
@ -301,7 +428,12 @@
|
||||||
<p>Histórico mensal de mudanças de plano/aparelho</p>
|
<p>Histórico mensal de mudanças de plano/aparelho</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('mureg12')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'mureg12')">
|
||||||
<canvas #chartMureg12></canvas>
|
<canvas #chartMureg12></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,7 +445,12 @@
|
||||||
<p>Histórico mensal de trocas realizadas</p>
|
<p>Histórico mensal de trocas realizadas</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('troca12')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'troca12')">
|
||||||
<canvas #chartTroca12></canvas>
|
<canvas #chartTroca12></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -326,7 +463,12 @@
|
||||||
<p>Contratos a encerrar por mês</p>
|
<p>Contratos a encerrar por mês</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('vigenciaMesAno')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'vigenciaMesAno')">
|
||||||
<canvas #chartVigenciaMesAno></canvas>
|
<canvas #chartVigenciaMesAno></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,7 +494,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body-split">
|
<div class="card-body-split">
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('status')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'status')">
|
||||||
<canvas #chartStatusPie></canvas>
|
<canvas #chartStatusPie></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-list">
|
<div class="status-list">
|
||||||
|
|
@ -373,7 +520,12 @@
|
||||||
<p>Quantidade de linhas por faixa de franquia contratada</p>
|
<p>Quantidade de linhas por faixa de franquia contratada</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact-half">
|
<div
|
||||||
|
class="chart-wrapper-bar compact-half chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('linhasFranquia')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'linhasFranquia')">
|
||||||
<canvas #chartLinhasPorFranquia></canvas>
|
<canvas #chartLinhasPorFranquia></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,7 +540,12 @@
|
||||||
<p>Planos com maior volume na sua operação</p>
|
<p>Planos com maior volume na sua operação</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact">
|
<div
|
||||||
|
class="chart-wrapper-bar compact chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoTopPlanos')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoTopPlanos')">
|
||||||
<canvas #chartResumoTopPlanos></canvas>
|
<canvas #chartResumoTopPlanos></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -401,7 +558,12 @@
|
||||||
<p>Apenas usuários de fato (sem bloqueados/aguardando)</p>
|
<p>Apenas usuários de fato (sem bloqueados/aguardando)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-bar compact">
|
<div
|
||||||
|
class="chart-wrapper-bar compact chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('resumoTopClientes')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'resumoTopClientes')">
|
||||||
<canvas #chartResumoTopClientes></canvas>
|
<canvas #chartResumoTopClientes></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -414,7 +576,12 @@
|
||||||
<p>Distribuição entre e-SIM, SIMCARD e outros</p>
|
<p>Distribuição entre e-SIM, SIMCARD e outros</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrapper-pie">
|
<div
|
||||||
|
class="chart-wrapper-pie chart-click-target"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
(click)="openChartModal('tipoChip')"
|
||||||
|
(keydown)="onChartTargetKeydown($event, 'tipoChip')">
|
||||||
<canvas #chartTipoChip></canvas>
|
<canvas #chartTipoChip></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -441,5 +608,54 @@
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="chart-modal-overlay"
|
||||||
|
*ngIf="chartModalOpen"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="chartModalTitle || 'Gráfico expandido'"
|
||||||
|
(click)="closeChartModal()">
|
||||||
|
<div class="chart-modal-card" (click)="$event.stopPropagation()">
|
||||||
|
<div class="chart-modal-header">
|
||||||
|
<div class="chart-modal-title-wrap">
|
||||||
|
<h3>{{ chartModalTitle }}</h3>
|
||||||
|
<p *ngIf="chartModalSubtitle">{{ chartModalSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chart-modal-close"
|
||||||
|
aria-label="Fechar gráfico expandido"
|
||||||
|
(click)="closeChartModal()">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-modal-body">
|
||||||
|
<div class="chart-modal-content">
|
||||||
|
<div class="chart-modal-visual">
|
||||||
|
<canvas #chartExpandedCanvas></canvas>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chart-modal-info"
|
||||||
|
*ngIf="chartModalInfoRows.length > 0"
|
||||||
|
[style.--dataset-cols]="chartModalDatasetHeaders.length">
|
||||||
|
<div class="chart-modal-info-head">
|
||||||
|
<span class="col-label">Categoria</span>
|
||||||
|
<span class="col-value" *ngFor="let dataset of chartModalDatasetHeaders">{{ dataset }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-modal-info-row" *ngFor="let row of chartModalInfoRows">
|
||||||
|
<span class="col-label">{{ row.label }}</span>
|
||||||
|
<span class="col-value" *ngFor="let cell of row.cells">{{ cell.valueText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-modal-info-total" *ngIf="chartModalDatasetTotals.length > 0">
|
||||||
|
<span *ngFor="let total of chartModalDatasetTotals">
|
||||||
|
Total {{ total.dataset }}: <strong>{{ total.totalText }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,72 @@
|
||||||
@media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; }
|
@media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operadora-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: -8px 0 22px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
@media (max-width: 840px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.operadora-filter-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba(227, 61, 207, 0.45);
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--brand-soft);
|
||||||
|
border-color: rgba(227, 61, 207, 0.45);
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.badge-pill {
|
.badge-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -392,6 +458,22 @@
|
||||||
&.compact-half { height: 200px; }
|
&.compact-half { height: 200px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-click-target {
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 18px -16px rgba(17, 18, 20, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(3, 15, 170, 0.26);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card-adicionais .card-body-adicionais {
|
.card-adicionais .card-body-adicionais {
|
||||||
padding: 14px 16px 12px;
|
padding: 14px 16px 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -630,7 +712,221 @@
|
||||||
@media(max-width: 1080px) { grid-template-columns: 1fr; }
|
@media(max-width: 1080px) { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vivo-comparison-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vivo-comparison-empty {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
background: rgba(10, 14, 35, 0.58);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-card {
|
||||||
|
width: min(1120px, 96vw);
|
||||||
|
max-height: min(86vh, 860px);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 30px 70px -26px rgba(2, 8, 23, 0.65);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalChartIn 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-title-wrap {
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(17, 18, 20, 0.7);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(227, 61, 207, 0.35);
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-body {
|
||||||
|
position: relative;
|
||||||
|
height: min(72vh, 680px);
|
||||||
|
min-height: 360px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr);
|
||||||
|
gap: 14px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-visual {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info {
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info-head,
|
||||||
|
.chart-modal-info-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(78px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #eef2ff;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #334155;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info-row {
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:nth-child(odd) {
|
||||||
|
background: #fdfdff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info .col-label {
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info .col-value {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info-total {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalChartIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Utils */
|
/* Utils */
|
||||||
.text-brand { color: var(--brand); }
|
.text-brand { color: var(--brand); }
|
||||||
.text-brand-dark { color: #b832a8; }
|
.text-brand-dark { color: #b832a8; }
|
||||||
.full-width { width: 100%; }
|
.full-width { width: 100%; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.chart-modal-overlay {
|
||||||
|
padding: 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-card {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-body {
|
||||||
|
min-height: 300px;
|
||||||
|
height: min(72vh, 620px);
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(200px, 1fr) minmax(140px, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-info-head,
|
||||||
|
.chart-modal-info-row {
|
||||||
|
grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(72px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -75,7 +75,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters-row mt-4" data-animate>
|
<div class="filters-stack mt-4" data-animate>
|
||||||
|
<div class="filters-row filters-row-top">
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<button type="button" class="filter-tab" [class.active]="filterSkil === 'ALL'" (click)="setFilter('ALL')" [disabled]="loading">
|
<button type="button" class="filter-tab" [class.active]="filterSkil === 'ALL'" (click)="setFilter('ALL')" [disabled]="loading">
|
||||||
Todos
|
Todos
|
||||||
|
|
@ -113,9 +114,41 @@
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters-row filters-row-bottom" *ngIf="!isClientRestricted">
|
||||||
|
<div class="operadora-empresa-filters" (click)="$event.stopPropagation()">
|
||||||
|
<div class="filter-select-box">
|
||||||
|
<app-select
|
||||||
|
class="select-glass"
|
||||||
|
size="sm"
|
||||||
|
[options]="operadoraFilterOptions"
|
||||||
|
labelKey="label"
|
||||||
|
valueKey="value"
|
||||||
|
[(ngModel)]="filterOperadora"
|
||||||
|
(ngModelChange)="setOperadoraFilter($event)"
|
||||||
|
[disabled]="loading"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-select-box">
|
||||||
|
<app-select
|
||||||
|
class="select-glass"
|
||||||
|
size="sm"
|
||||||
|
[options]="contaEmpresaFilterOptions"
|
||||||
|
labelKey="label"
|
||||||
|
valueKey="value"
|
||||||
|
[(ngModel)]="filterContaEmpresa"
|
||||||
|
(ngModelChange)="setContaEmpresaFilter($event)"
|
||||||
|
[searchable]="true"
|
||||||
|
searchPlaceholder="Buscar empresa..."
|
||||||
|
[disabled]="loading || loadingAccountCompanies"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- CLIENTE MULTI-SELECT -->
|
<!-- CLIENTE MULTI-SELECT -->
|
||||||
<div class="client-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
|
<div class="client-filter-wrap" (click)="$event.stopPropagation()">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-client-filter"
|
class="btn-client-filter"
|
||||||
|
|
@ -168,7 +201,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="additional-filter-wrap" *ngIf="!isClientRestricted" (click)="$event.stopPropagation()">
|
<div class="additional-filter-wrap" (click)="$event.stopPropagation()">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-client-filter btn-additional-filter"
|
class="btn-client-filter btn-additional-filter"
|
||||||
|
|
@ -253,6 +286,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="geral-kpis mt-4 animate-fade-in" [class.geral-kpis-client]="isClientRestricted" *ngIf="isGroupMode">
|
<div class="geral-kpis mt-4 animate-fade-in" [class.geral-kpis-client]="isClientRestricted" *ngIf="isGroupMode">
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,35 @@
|
||||||
.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; } }
|
.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 e Multi-Select */
|
/* Filtros e Multi-Select */
|
||||||
.filters-row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
|
.filters-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row-top {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row-bottom {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); }
|
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); }
|
||||||
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
|
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
|
||||||
|
|
||||||
.client-filter-wrap { position: relative; }
|
.client-filter-wrap { position: relative; z-index: 40; }
|
||||||
.btn-client-filter { display: flex; align-items: center; gap: 8px; 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); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } }
|
.btn-client-filter { display: flex; align-items: center; gap: 8px; 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); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } }
|
||||||
.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
|
.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
|
||||||
.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; cursor: default; user-select: none; }
|
.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; cursor: default; user-select: none; }
|
||||||
|
|
@ -140,6 +164,34 @@
|
||||||
|
|
||||||
.additional-filter-wrap {
|
.additional-filter-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operadora-empresa-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 190px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(17, 18, 20, 0.58);
|
||||||
|
padding-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-additional-filter {
|
.btn-additional-filter {
|
||||||
|
|
@ -249,6 +301,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.operadora-empresa-filters {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-box {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* KPIs */
|
/* KPIs */
|
||||||
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
|
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
|
||||||
.geral-kpis.geral-kpis-client {
|
.geral-kpis.geral-kpis-client {
|
||||||
|
|
@ -866,3 +930,421 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
|
||||||
.summary-pill { font-size: 0.72rem; }
|
.summary-pill { font-size: 0.72rem; }
|
||||||
.batch-validation-banner { align-items: flex-start; }
|
.batch-validation-banner { align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-mve-audit {
|
||||||
|
width: min(1320px, calc(100vw - 40px));
|
||||||
|
max-width: min(1320px, calc(100vw - 40px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-audit-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-card,
|
||||||
|
.mve-upload-card {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,249,252,0.98));
|
||||||
|
border: 1px solid rgba(17,18,20,0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 16px 32px rgba(17,18,20,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-text {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: rgba(17,18,20,0.68);
|
||||||
|
max-width: 760px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-meta,
|
||||||
|
.mve-summary-notes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(3,15,170,0.06);
|
||||||
|
color: rgba(17,18,20,0.76);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
border: 1px solid rgba(3,15,170,0.08);
|
||||||
|
|
||||||
|
&.accent {
|
||||||
|
background: rgba(12,132,78,0.08);
|
||||||
|
border-color: rgba(12,132,78,0.16);
|
||||||
|
color: #0c6c43;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-section-title {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-upload-zone {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
border: 1.5px dashed rgba(3,15,170,0.22);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(3,15,170,0.08), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(243,247,255,0.92));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, transform 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(3,15,170,0.38);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(3,15,170,0.08);
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtitle,
|
||||||
|
.processing-text,
|
||||||
|
.mve-upload-meta {
|
||||||
|
color: rgba(17,18,20,0.62);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-upload-actions,
|
||||||
|
.mve-actions-row,
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-upload-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-processing {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(17,18,20,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-indeterminate {
|
||||||
|
display: block;
|
||||||
|
width: 34%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #030faa, #2b55e3);
|
||||||
|
animation: mve-progress 1.25s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mve-progress {
|
||||||
|
from { transform: translateX(-120%); }
|
||||||
|
to { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-summary-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(17,18,20,0.07);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 12px 24px rgba(17,18,20,0.05);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 1.36rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-positive strong { color: #198754; }
|
||||||
|
&.is-danger strong { color: #b42318; }
|
||||||
|
&.is-warning strong { color: #b54708; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(17,18,20,0.58);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-result-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-search-group {
|
||||||
|
min-width: min(420px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-sync-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(12,132,78,0.08);
|
||||||
|
border: 1px solid rgba(12,132,78,0.16);
|
||||||
|
color: #0c6c43;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 180px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px dashed rgba(17,18,20,0.14);
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
color: rgba(17,18,20,0.62);
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-table-wrap {
|
||||||
|
max-height: min(48vh, 560px);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-table {
|
||||||
|
min-width: 980px;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(248,249,250,0.97);
|
||||||
|
font-size: 0.73rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgba(17,18,20,0.64);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-status-pair,
|
||||||
|
.mve-cell-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-status-pair {
|
||||||
|
.bi-arrow-right {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-issue-tag,
|
||||||
|
.mve-severity {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 900;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&.status { background: rgba(180,35,24,0.09); color: #b42318; border-color: rgba(180,35,24,0.16); }
|
||||||
|
&.data { background: rgba(181,71,8,0.09); color: #b54708; border-color: rgba(181,71,8,0.16); }
|
||||||
|
&.system { background: rgba(3,15,170,0.08); color: var(--blue); border-color: rgba(3,15,170,0.15); }
|
||||||
|
&.report { background: rgba(10,91,168,0.08); color: #0a5ba8; border-color: rgba(10,91,168,0.15); }
|
||||||
|
&.duplicate { background: rgba(113,46,170,0.08); color: #712eaa; border-color: rgba(113,46,170,0.14); }
|
||||||
|
&.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.16); }
|
||||||
|
&.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.72); border-color: rgba(17,18,20,0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-severity {
|
||||||
|
&.critical { background: rgba(180,35,24,0.08); color: #b42318; border-color: rgba(180,35,24,0.15); }
|
||||||
|
&.medium { background: rgba(181,71,8,0.08); color: #b54708; border-color: rgba(181,71,8,0.14); }
|
||||||
|
&.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.18); }
|
||||||
|
&.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.65); border-color: rgba(17,18,20,0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-differences-text {
|
||||||
|
color: rgba(17,18,20,0.78);
|
||||||
|
line-height: 1.38;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-actions-row {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-confirm-overlay {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-confirm-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(245,248,255,0.98));
|
||||||
|
border: 1px solid rgba(3,15,170,0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 18px 36px rgba(17,18,20,0.12);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-text,
|
||||||
|
.confirm-footnote {
|
||||||
|
color: rgba(17,18,20,0.68);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(3,15,170,0.05);
|
||||||
|
border: 1px solid rgba(3,15,170,0.08);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.mve-summary-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-mve-audit {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
max-width: calc(100vw - 20px);
|
||||||
|
max-height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-intro-card,
|
||||||
|
.mve-result-toolbar,
|
||||||
|
.mve-footer,
|
||||||
|
.mve-sync-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-toolbar-right,
|
||||||
|
.mve-actions-row,
|
||||||
|
.confirm-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-toolbar-right > *,
|
||||||
|
.mve-actions-row .btn,
|
||||||
|
.confirm-actions .btn,
|
||||||
|
.mve-upload-actions .btn {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-search-group {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,36 @@ describe('Geral', () => {
|
||||||
expect(component.createBatchLines[0].linha).toBe('11888888888');
|
expect(component.createBatchLines[0].linha).toBe('11888888888');
|
||||||
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B');
|
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should apply TIM filter in client-side pipeline using conta TIM textual', () => {
|
||||||
|
component.filterOperadora = 'TIM';
|
||||||
|
component.filterContaEmpresa = '';
|
||||||
|
component.filterStatus = 'ALL';
|
||||||
|
component.additionalMode = 'ALL';
|
||||||
|
component.selectedAdditionalServices = [];
|
||||||
|
|
||||||
|
const filtered = (component as any).applyAdditionalFiltersClientSide([
|
||||||
|
{ id: '1', item: 1, conta: 'TIM', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
|
||||||
|
{ id: '2', item: 2, conta: '455371844', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filtered.length).toBe(1);
|
||||||
|
expect(filtered[0].conta).toBe('TIM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine operadora and empresa filters for VIVO MACROPHONY', () => {
|
||||||
|
component.filterOperadora = 'VIVO';
|
||||||
|
component.filterContaEmpresa = 'VIVO MACROPHONY';
|
||||||
|
component.filterStatus = 'ALL';
|
||||||
|
component.additionalMode = 'ALL';
|
||||||
|
component.selectedAdditionalServices = [];
|
||||||
|
|
||||||
|
const filtered = (component as any).applyAdditionalFiltersClientSide([
|
||||||
|
{ id: '1', item: 1, conta: '460161507', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
|
||||||
|
{ id: '2', item: 2, conta: '0435288088', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filtered.length).toBe(1);
|
||||||
|
expect(filtered[0].conta).toBe('460161507');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,226 @@
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
|
||||||
|
<div #feedbackToast 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="mve-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="page-shell">
|
||||||
|
<div class="page-card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<div class="title-badge">
|
||||||
|
<i class="bi bi-shield-check"></i> Relatorio da Vivo
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-title">
|
||||||
|
<h5 class="title mb-0">Auditoria MVE</h5>
|
||||||
|
<small class="subtitle">Compare os status das linhas e atualize o sistema quando precisar.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn btn-glass btn-sm" (click)="loadLatestAudit()" [disabled]="loadingLatest || processing || syncing">
|
||||||
|
<span *ngIf="!loadingLatest"><i class="bi bi-arrow-clockwise me-1"></i> Ultima conferencia</span>
|
||||||
|
<span *ngIf="loadingLatest"><span class="spinner-border spinner-border-sm me-2"></span> Carregando...</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-brand btn-sm" (click)="syncStatuses()" [disabled]="syncing || syncableStatusIssues.length === 0">
|
||||||
|
<span *ngIf="!syncing"><i class="bi bi-arrow-repeat me-1"></i> Atualizar sistema</span>
|
||||||
|
<span *ngIf="syncing"><span class="spinner-border spinner-border-sm me-2"></span> Sincronizando...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="intro-card">
|
||||||
|
<div>
|
||||||
|
<div class="intro-title">Conferencia</div>
|
||||||
|
<p class="intro-text mb-0">
|
||||||
|
Use o relatorio da Vivo para conferir se o <strong>status da linha</strong> esta igual ao do sistema.
|
||||||
|
Os outros campos nao entram como erro nesta tela.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="intro-meta" *ngIf="auditResult">
|
||||||
|
<span class="meta-pill"><i class="bi bi-filetype-csv me-1"></i> {{ auditResult.fileName || 'arquivo sem nome' }}</span>
|
||||||
|
<span class="meta-pill">Ultima conferencia: {{ formatDateTime(auditResult.importedAtUtc) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Enviar relatorio</div>
|
||||||
|
<div class="section-subtitle">Selecione o arquivo da Vivo para conferir os status.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-grid">
|
||||||
|
<label class="upload-zone">
|
||||||
|
<input type="file" accept=".csv,text/csv" (change)="onFileSelected($event)" [disabled]="processing || syncing" />
|
||||||
|
<span class="upload-icon"><i class="bi bi-cloud-arrow-up"></i></span>
|
||||||
|
<span class="upload-title">{{ selectedFile ? selectedFile.name : 'Selecionar relatorio da Vivo' }}</span>
|
||||||
|
<span class="upload-subtitle">Escolha o arquivo exportado pela Vivo.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="upload-actions">
|
||||||
|
<button type="button" class="btn btn-glass btn-sm" (click)="clearSelectedFile()" [disabled]="!selectedFile || processing">
|
||||||
|
Limpar arquivo
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-brand btn-sm" (click)="processAudit()" [disabled]="!selectedFile || processing || syncing">
|
||||||
|
<span *ngIf="!processing"><i class="bi bi-play-circle me-1"></i> Conferir relatorio</span>
|
||||||
|
<span *ngIf="processing"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger mb-0 mt-3" *ngIf="errorMessage">
|
||||||
|
<i class="bi bi-exclamation-octagon me-2"></i>{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="apply-banner" *ngIf="applyResult">
|
||||||
|
<strong>Sincronização concluída.</strong>
|
||||||
|
{{ applyResult.updatedLines }} linha(s) atualizada(s).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body" *ngIf="auditResult as audit; else emptyState">
|
||||||
|
<div class="summary-grid">
|
||||||
|
<article class="summary-card">
|
||||||
|
<span class="summary-label">No sistema</span>
|
||||||
|
<strong>{{ audit.summary.totalSystemLines }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card">
|
||||||
|
<span class="summary-label">No relatorio</span>
|
||||||
|
<strong>{{ audit.summary.totalReportLines }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card is-positive">
|
||||||
|
<span class="summary-label">Sem diferenca</span>
|
||||||
|
<strong>{{ audit.summary.totalConciliated }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card is-danger">
|
||||||
|
<span class="summary-label">Com diferenca</span>
|
||||||
|
<strong>{{ audit.summary.totalStatusDivergences }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card is-brand">
|
||||||
|
<span class="summary-label">Prontas para atualizar</span>
|
||||||
|
<strong>{{ syncableStatusIssues.length }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="secondary-notes" *ngIf="audit.summary.totalOnlyInSystem > 0 || audit.summary.totalOnlyInReport > 0">
|
||||||
|
<span class="meta-pill" *ngIf="audit.summary.totalOnlyInSystem > 0">Só no sistema: {{ audit.summary.totalOnlyInSystem }}</span>
|
||||||
|
<span class="meta-pill" *ngIf="audit.summary.totalOnlyInReport > 0">Só no relatório: {{ audit.summary.totalOnlyInReport }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="view-tabs">
|
||||||
|
<button type="button" class="filter-tab" [class.active]="viewMode === 'PENDING'" (click)="setViewMode('PENDING')">
|
||||||
|
Para atualizar
|
||||||
|
</button>
|
||||||
|
<button type="button" class="filter-tab" [class.active]="viewMode === 'APPLIED'" (click)="setViewMode('APPLIED')">
|
||||||
|
Atualizadas
|
||||||
|
</button>
|
||||||
|
<button type="button" class="filter-tab" [class.active]="viewMode === 'ALL'" (click)="setViewMode('ALL')">
|
||||||
|
Todas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<div class="input-group input-group-sm search-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Buscar por linha ou status"
|
||||||
|
[(ngModel)]="searchTerm"
|
||||||
|
(ngModelChange)="onSearchChange()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select class="form-select form-select-sm page-size-select" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()">
|
||||||
|
<option *ngFor="let option of pageSizeOptions" [ngValue]="option">{{ option }} por página</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-empty" *ngIf="filteredStatusIssues.length === 0">
|
||||||
|
<i class="bi bi-check2-circle"></i>
|
||||||
|
<div>Nenhuma diferenca de status encontrada para o filtro atual.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap" *ngIf="filteredStatusIssues.length > 0">
|
||||||
|
<table class="table table-modern align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Número</th>
|
||||||
|
<th>Status no sistema</th>
|
||||||
|
<th>Status no relatorio</th>
|
||||||
|
<th>Situação</th>
|
||||||
|
<th>Ação</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let issue of pagedStatusIssues; trackBy: trackByIssue">
|
||||||
|
<td>
|
||||||
|
<span class="line-number-chip">{{ issue.numeroLinha || '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill" [ngClass]="statusClass(issue.systemStatus)">{{ statusLabel(issue.systemStatus) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill" [ngClass]="statusClass(issue.reportStatus)">{{ statusLabel(issue.reportStatus) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="status-diff-copy">Status diferente.</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="sync-badge ready" *ngIf="issue.syncable && !issue.applied">Pode atualizar</span>
|
||||||
|
<span class="sync-badge applied" *ngIf="issue.applied">Atualizada</span>
|
||||||
|
<span class="sync-badge muted" *ngIf="!issue.syncable && !issue.applied">Sem ação</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-footer" *ngIf="filteredStatusIssues.length > 0">
|
||||||
|
<div class="small text-muted fw-bold">
|
||||||
|
Mostrando {{ pageStart }}–{{ pageEnd }} de {{ filteredStatusIssues.length }} divergência(s) de status
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0 pagination-modern">
|
||||||
|
<li class="page-item" [class.disabled]="page === 1">
|
||||||
|
<button class="page-link" type="button" (click)="goToPage(page - 1)">Anterior</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
|
||||||
|
<button class="page-link" type="button" (click)="goToPage(p)">{{ p }}</button>
|
||||||
|
</li>
|
||||||
|
<li class="page-item" [class.disabled]="page === totalPages">
|
||||||
|
<button class="page-link" type="button" (click)="goToPage(page + 1)">Próxima</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #emptyState>
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet"></i>
|
||||||
|
<div>Nenhuma conferencia carregada ainda.</div>
|
||||||
|
<small>Envie o relatorio da Vivo para ver as diferencas de status e atualizar o sistema.</small>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,660 @@
|
||||||
|
:host {
|
||||||
|
--brand: #e33dcf;
|
||||||
|
--brand-deep: #972688;
|
||||||
|
--brand-soft: rgba(227, 61, 207, 0.1);
|
||||||
|
--ink: #17161d;
|
||||||
|
--muted: rgba(23, 22, 29, 0.68);
|
||||||
|
--surface: rgba(255, 255, 255, 0.84);
|
||||||
|
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||||
|
--line: rgba(23, 22, 29, 0.08);
|
||||||
|
--shadow: 0 24px 60px rgba(24, 17, 33, 0.12);
|
||||||
|
--success: #198754;
|
||||||
|
--danger: #dc3545;
|
||||||
|
--warning: #ffb200;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mve-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 14px 120px;
|
||||||
|
background:
|
||||||
|
radial-gradient(720px 380px at 12% 8%, rgba(227, 61, 207, 0.16), transparent 60%),
|
||||||
|
radial-gradient(620px 360px at 88% 12%, rgba(3, 15, 170, 0.08), transparent 58%),
|
||||||
|
linear-gradient(180deg, #fff 0%, #f6f4f8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-blob {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(48px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.42;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.6), rgba(227, 61, 207, 0.04));
|
||||||
|
|
||||||
|
&.blob-1 { width: 420px; height: 420px; top: -180px; left: -120px; }
|
||||||
|
&.blob-2 { width: 520px; height: 520px; top: -220px; right: -220px; }
|
||||||
|
&.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 20%; }
|
||||||
|
&.blob-4 { width: 420px; height: 420px; bottom: -200px; right: 12%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(1480px, 98vw);
|
||||||
|
margin: 38px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.72));
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header > * {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-badge {
|
||||||
|
justify-self: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid rgba(227, 61, 207, 0.22);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-self: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-glass {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid rgba(23, 22, 29, 0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand {
|
||||||
|
background: linear-gradient(135deg, var(--brand), var(--brand-deep));
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 12px 24px rgba(151, 38, 136, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-card,
|
||||||
|
.upload-card,
|
||||||
|
.secondary-notes,
|
||||||
|
.toolbar,
|
||||||
|
.summary-grid,
|
||||||
|
.table-wrap,
|
||||||
|
.status-empty,
|
||||||
|
.empty-state {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-card,
|
||||||
|
.upload-card,
|
||||||
|
.toolbar,
|
||||||
|
.table-wrap,
|
||||||
|
.status-empty,
|
||||||
|
.empty-state {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 18px 34px rgba(24, 17, 33, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-title,
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-text,
|
||||||
|
.section-subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-meta,
|
||||||
|
.secondary-notes {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(24, 17, 33, 0.05);
|
||||||
|
border: 1px solid rgba(24, 17, 33, 0.08);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(24, 17, 33, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px dashed rgba(227, 61, 207, 0.35);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.85)),
|
||||||
|
#fff;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(227, 61, 207, 0.12);
|
||||||
|
color: var(--brand-deep);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtitle {
|
||||||
|
max-width: 540px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-banner {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(25, 135, 84, 0.1);
|
||||||
|
border: 1px solid rgba(25, 135, 84, 0.18);
|
||||||
|
color: #11653d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 16px 30px rgba(24, 17, 33, 0.08);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-positive strong {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger strong {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-brand strong {
|
||||||
|
color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
border: 1px solid rgba(24, 17, 33, 0.08);
|
||||||
|
background: rgba(24, 17, 33, 0.04);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(227, 61, 207, 0.14);
|
||||||
|
border-color: rgba(227, 61, 207, 0.24);
|
||||||
|
color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group {
|
||||||
|
min-width: min(360px, 86vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-select {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-modern {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #faf7fc;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: rgba(24, 17, 33, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid rgba(24, 17, 33, 0.06);
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-number-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(3, 15, 170, 0.08);
|
||||||
|
border: 1px solid rgba(3, 15, 170, 0.18);
|
||||||
|
color: #030faa;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 18px rgba(3, 15, 170, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: rgba(25, 135, 84, 0.12);
|
||||||
|
color: #11653d;
|
||||||
|
border-color: rgba(25, 135, 84, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-blocked {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
color: #9f1d2d;
|
||||||
|
border-color: rgba(220, 53, 69, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-neutral {
|
||||||
|
background: rgba(24, 17, 33, 0.06);
|
||||||
|
color: rgba(24, 17, 33, 0.72);
|
||||||
|
border-color: rgba(24, 17, 33, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-diff-copy {
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(24, 17, 33, 0.82);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
background: rgba(255, 178, 0, 0.16);
|
||||||
|
color: #8c6200;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.applied {
|
||||||
|
background: rgba(25, 135, 84, 0.14);
|
||||||
|
color: #11653d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
background: rgba(24, 17, 33, 0.06);
|
||||||
|
color: rgba(24, 17, 33, 0.62);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-empty,
|
||||||
|
.empty-state {
|
||||||
|
padding: 42px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 34px;
|
||||||
|
color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state small {
|
||||||
|
max-width: 560px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.header-top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-badge,
|
||||||
|
.header-actions {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page-shell {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.page-body {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header > *,
|
||||||
|
.page-body > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right,
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .btn,
|
||||||
|
.upload-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
min-height: 150px;
|
||||||
|
padding: 18px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-meta,
|
||||||
|
.secondary-notes,
|
||||||
|
.view-tabs,
|
||||||
|
.toolbar {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tabs {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
flex: 1 1 120px;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group,
|
||||||
|
.page-size-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-modern {
|
||||||
|
min-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-number-chip {
|
||||||
|
min-width: 108px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.mve-page {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.page-body {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-badge,
|
||||||
|
.meta-pill,
|
||||||
|
.filter-tab,
|
||||||
|
.status-pill,
|
||||||
|
.sync-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer .pagination {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
import { Component, ChangeDetectorRef, ElementRef, Inject, OnInit, PLATFORM_ID, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MveAuditService,
|
||||||
|
type ApplyMveAuditResult,
|
||||||
|
type MveAuditIssue,
|
||||||
|
type MveAuditRun,
|
||||||
|
} from '../../services/mve-audit.service';
|
||||||
|
import { confirmActionModal } from '../../utils/destructive-confirmation';
|
||||||
|
import {
|
||||||
|
buildPageNumbers,
|
||||||
|
clampPage,
|
||||||
|
computePageEnd,
|
||||||
|
computePageStart,
|
||||||
|
computeTotalPages,
|
||||||
|
} from '../../utils/pagination.util';
|
||||||
|
|
||||||
|
type MveStatusViewMode = 'PENDING' | 'APPLIED' | 'ALL';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-mve-auditoria',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './mve-auditoria.html',
|
||||||
|
styleUrls: ['./mve-auditoria.scss'],
|
||||||
|
})
|
||||||
|
export class MveAuditoriaPage implements OnInit {
|
||||||
|
@ViewChild('feedbackToast', { static: false }) feedbackToast?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
loadingLatest = false;
|
||||||
|
processing = false;
|
||||||
|
syncing = false;
|
||||||
|
selectedFile: File | null = null;
|
||||||
|
auditResult: MveAuditRun | null = null;
|
||||||
|
applyResult: ApplyMveAuditResult | null = null;
|
||||||
|
errorMessage = '';
|
||||||
|
toastMessage = '';
|
||||||
|
|
||||||
|
searchTerm = '';
|
||||||
|
viewMode: MveStatusViewMode = 'PENDING';
|
||||||
|
page = 1;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizeOptions = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly mveAuditService: MveAuditService,
|
||||||
|
private readonly cdr: ChangeDetectorRef,
|
||||||
|
@Inject(PLATFORM_ID) private readonly platformId: object
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.errorMessage = '';
|
||||||
|
const cachedRun = this.mveAuditService.getCachedRun();
|
||||||
|
if (cachedRun) {
|
||||||
|
this.auditResult = cachedRun;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.restoreCachedAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasAuditResult(): boolean {
|
||||||
|
return !!this.auditResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
get syncableStatusIssues(): MveAuditIssue[] {
|
||||||
|
return this.statusIssues.filter((issue) => issue.syncable && !issue.applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusIssues(): MveAuditIssue[] {
|
||||||
|
const issues = this.auditResult?.issues ?? [];
|
||||||
|
return issues
|
||||||
|
.filter((issue) => this.issueHasStatusDifference(issue))
|
||||||
|
.sort((left, right) => Number(left.applied) - Number(right.applied));
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredStatusIssues(): MveAuditIssue[] {
|
||||||
|
const query = this.normalizeSearch(this.searchTerm);
|
||||||
|
return this.statusIssues.filter((issue) => {
|
||||||
|
if (this.viewMode === 'PENDING' && issue.applied) return false;
|
||||||
|
if (this.viewMode === 'APPLIED' && !issue.applied) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
issue.numeroLinha,
|
||||||
|
issue.systemStatus,
|
||||||
|
issue.reportStatus,
|
||||||
|
issue.situation,
|
||||||
|
issue.notes,
|
||||||
|
]
|
||||||
|
.map((value) => this.normalizeSearch(value))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagedStatusIssues(): MveAuditIssue[] {
|
||||||
|
const offset = (this.page - 1) * this.pageSize;
|
||||||
|
return this.filteredStatusIssues.slice(offset, offset + this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalPages(): number {
|
||||||
|
return computeTotalPages(this.filteredStatusIssues.length, this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageNumbers(): number[] {
|
||||||
|
return buildPageNumbers(this.page, this.totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageStart(): number {
|
||||||
|
return computePageStart(this.filteredStatusIssues.length, this.page, this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageEnd(): number {
|
||||||
|
return computePageEnd(this.filteredStatusIssues.length, this.page, this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
get ignoredIssuesCount(): number {
|
||||||
|
if (!this.auditResult) return 0;
|
||||||
|
const summary = this.auditResult.summary;
|
||||||
|
return (
|
||||||
|
summary.totalOnlyInSystem +
|
||||||
|
summary.totalOnlyInReport +
|
||||||
|
summary.totalDuplicateReportLines +
|
||||||
|
summary.totalDuplicateSystemLines +
|
||||||
|
summary.totalInvalidRows +
|
||||||
|
summary.totalUnknownStatuses
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLatestAudit(): Promise<void> {
|
||||||
|
this.loadingLatest = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.auditResult = await firstValueFrom(this.mveAuditService.getLatest());
|
||||||
|
this.applyResult = null;
|
||||||
|
this.page = 1;
|
||||||
|
} catch (error) {
|
||||||
|
const httpError = error as HttpErrorResponse | null;
|
||||||
|
if (httpError?.status !== 404) {
|
||||||
|
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel carregar a ultima conferencia.');
|
||||||
|
}
|
||||||
|
this.auditResult = null;
|
||||||
|
} finally {
|
||||||
|
this.loadingLatest = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0] ?? null;
|
||||||
|
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.applyResult = null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
this.selectedFile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.errorMessage = 'Selecione o relatorio exportado pela Vivo.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size <= 0) {
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.errorMessage = 'O arquivo selecionado está vazio.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.errorMessage = 'O arquivo excede o limite de 20 MB.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelectedFile(): void {
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.errorMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async processAudit(): Promise<void> {
|
||||||
|
if (!this.selectedFile || this.processing || this.syncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.applyResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.auditResult = await firstValueFrom(this.mveAuditService.preview(this.selectedFile));
|
||||||
|
this.page = 1;
|
||||||
|
this.viewMode = 'PENDING';
|
||||||
|
this.searchTerm = '';
|
||||||
|
await this.showToast('Relatorio conferido com sucesso.');
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel conferir o relatorio.');
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncStatuses(): Promise<void> {
|
||||||
|
if (!this.auditResult || this.syncableStatusIssues.length === 0 || this.syncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirmActionModal({
|
||||||
|
title: 'Atualizar status no sistema',
|
||||||
|
message: `${this.syncableStatusIssues.length} linha(s) terao o status atualizado de acordo com o relatorio da Vivo.`,
|
||||||
|
confirmLabel: 'Atualizar agora',
|
||||||
|
cancelLabel: 'Cancelar',
|
||||||
|
tone: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncing = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.applyResult = await firstValueFrom(this.mveAuditService.apply(this.auditResult.id));
|
||||||
|
this.auditResult = await firstValueFrom(this.mveAuditService.getById(this.auditResult.id));
|
||||||
|
this.viewMode = 'ALL';
|
||||||
|
this.page = 1;
|
||||||
|
await this.showToast('Status atualizados com sucesso.');
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar os status.');
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange(): void {
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageSizeChange(): void {
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewMode(mode: MveStatusViewMode): void {
|
||||||
|
this.viewMode = mode;
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number): void {
|
||||||
|
this.page = clampPage(page, this.totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByIssue(_: number, issue: MveAuditIssue): string {
|
||||||
|
return issue.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(value?: string | null): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string | null | undefined): string {
|
||||||
|
const normalized = (status ?? '').toLowerCase();
|
||||||
|
if (normalized.includes('bloq') || normalized.includes('perda') || normalized.includes('roubo')) return 'is-blocked';
|
||||||
|
if (normalized.includes('ativo')) return 'is-active';
|
||||||
|
return 'is-neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string | null | undefined): string {
|
||||||
|
const value = (status ?? '').trim();
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
private issueHasStatusDifference(issue: MveAuditIssue): boolean {
|
||||||
|
return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreCachedAudit(): Promise<void> {
|
||||||
|
const cachedRunId = this.mveAuditService.getCachedRunId();
|
||||||
|
if (!cachedRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingLatest = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restoredRun = await firstValueFrom(this.mveAuditService.restoreCachedRun());
|
||||||
|
if (!restoredRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.auditResult = restoredRun;
|
||||||
|
this.applyResult = null;
|
||||||
|
this.page = 1;
|
||||||
|
} finally {
|
||||||
|
this.loadingLatest = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSearch(value: unknown): string {
|
||||||
|
return (value ?? '')
|
||||||
|
.toString()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractHttpMessage(error: unknown, fallbackMessage: string): string {
|
||||||
|
const httpError = error as HttpErrorResponse | null;
|
||||||
|
if (httpError?.status === 0) {
|
||||||
|
return 'A API do LineGestao nao respondeu em http://localhost:5298. Inicie o backend e tente novamente.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(httpError?.error as { message?: string } | null)?.message ||
|
||||||
|
httpError?.message ||
|
||||||
|
fallbackMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showToast(message: string): Promise<void> {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
|
this.toastMessage = message;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
|
||||||
|
if (!this.feedbackToast?.nativeElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bootstrap = await import('bootstrap');
|
||||||
|
const instance = bootstrap.Toast.getOrCreateInstance(this.feedbackToast.nativeElement, {
|
||||||
|
autohide: true,
|
||||||
|
delay: 3200,
|
||||||
|
});
|
||||||
|
instance.show();
|
||||||
|
} catch {
|
||||||
|
// ignora falha de feedback visual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="bulk-btn ghost"
|
class="bulk-btn ghost export-glass"
|
||||||
(click)="exportNotifications()"
|
(click)="exportNotifications()"
|
||||||
[disabled]="exportLoading || filteredNotifications.length === 0"
|
[disabled]="exportLoading || filteredNotifications.length === 0"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,13 @@ $border: #e5e7eb;
|
||||||
&:disabled { opacity: 0.6; cursor: default; }
|
&:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
&.ghost { background: transparent; }
|
&.ghost { background: transparent; }
|
||||||
|
&.export-glass {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-color: rgba(28, 56, 201, 0.22);
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FILTROS (Estilo Tabs/Pills) */
|
/* FILTROS (Estilo Tabs/Pills) */
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
|
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
|
||||||
<i class="bi bi-arrow-repeat"></i> Atualizar
|
<i class="bi bi-arrow-repeat"></i> Atualizar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-ghost" type="button" (click)="onExport()" [disabled]="loading || exporting">
|
<button class="btn-ghost btn-export-glass" type="button" (click)="onExport()" [disabled]="loading || exporting">
|
||||||
<span *ngIf="!exporting"><i class="bi bi-download"></i> Exportar</span>
|
<span *ngIf="!exporting"><i class="bi bi-download"></i> Exportar</span>
|
||||||
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
|
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,13 @@
|
||||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-export-glass {
|
||||||
|
color: var(--pg-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
border-color: rgba(31, 79, 214, 0.24);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(145deg, #cf3131, #a91f1f);
|
background: linear-gradient(145deg, #cf3131, #a91f1f);
|
||||||
|
|
@ -226,6 +233,12 @@
|
||||||
color: var(--pg-primary-strong);
|
color: var(--pg-primary-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-export-glass:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(227, 61, 207, 0.3);
|
||||||
|
color: #e33dcf;
|
||||||
|
}
|
||||||
|
|
||||||
.lg-modal-card {
|
.lg-modal-card {
|
||||||
width: min(1180px, 98vw);
|
width: min(1180px, 98vw);
|
||||||
max-height: 92vh;
|
max-height: 92vh;
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
|
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
|
||||||
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
|
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()" [disabled]="isExporting('macrophony-planos')">
|
<button class="btn-icon-text btn-export-glass" type="button" (click)="exportMacrophonyCsv()" [disabled]="isExporting('macrophony-planos')">
|
||||||
<i class="bi" [class.bi-download]="!isExporting('macrophony-planos')" [class.bi-hourglass-split]="isExporting('macrophony-planos')"></i>
|
<i class="bi" [class.bi-download]="!isExporting('macrophony-planos')" [class.bi-hourglass-split]="isExporting('macrophony-planos')"></i>
|
||||||
<span class="hide-mobile">{{ isExporting('macrophony-planos') ? 'Exportando...' : 'Exportar' }}</span>
|
<span class="hide-mobile">{{ isExporting('macrophony-planos') ? 'Exportando...' : 'Exportar' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -447,7 +447,7 @@
|
||||||
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
|
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
|
||||||
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
|
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)" [disabled]="isExporting(file)">
|
<button class="btn-icon-text btn-export-glass" type="button" (click)="exportGroupedCsv(group, file)" [disabled]="isExporting(file)">
|
||||||
<i class="bi" [class.bi-download]="!isExporting(file)" [class.bi-hourglass-split]="isExporting(file)"></i>
|
<i class="bi" [class.bi-download]="!isExporting(file)" [class.bi-hourglass-split]="isExporting(file)"></i>
|
||||||
<span class="hide-mobile">{{ isExporting(file) ? 'Exportando...' : 'Exportar' }}</span>
|
<span class="hide-mobile">{{ isExporting(file) ? 'Exportando...' : 'Exportar' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,19 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-export-glass {
|
||||||
|
color: var(--blue);
|
||||||
|
border-color: rgba(3, 15, 170, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export-glass:hover:not(:disabled) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { MveAuditService } from './mve-audit.service';
|
||||||
|
|
||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -43,7 +44,10 @@ export class AuthService {
|
||||||
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
|
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
|
||||||
private readonly rememberMeHours = 6;
|
private readonly rememberMeHours = 6;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private readonly mveAuditService: MveAuditService
|
||||||
|
) {
|
||||||
this.syncUserProfileFromToken();
|
this.syncUserProfileFromToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,10 +69,12 @@ export class AuthService {
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
this.mveAuditService.clearCache();
|
||||||
this.userProfileSubject.next(null);
|
this.userProfileSubject.next(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.mveAuditService.clearCache();
|
||||||
this.clearTokenStorage(localStorage);
|
this.clearTokenStorage(localStorage);
|
||||||
this.clearTokenStorage(sessionStorage);
|
this.clearTokenStorage(sessionStorage);
|
||||||
this.userProfileSubject.next(null);
|
this.userProfileSubject.next(null);
|
||||||
|
|
@ -76,6 +82,7 @@ export class AuthService {
|
||||||
|
|
||||||
setToken(token: string, rememberMe = false) {
|
setToken(token: string, rememberMe = false) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
this.mveAuditService.clearCache();
|
||||||
this.clearTokenStorage(localStorage);
|
this.clearTokenStorage(localStorage);
|
||||||
this.clearTokenStorage(sessionStorage);
|
this.clearTokenStorage(sessionStorage);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { buildApiBaseUrl } from '../utils/api-base.util';
|
||||||
|
|
||||||
|
export const MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY = 'linegestao.mveAudit.currentRunId';
|
||||||
|
|
||||||
|
export interface MveAuditSummary {
|
||||||
|
totalSystemLines: number;
|
||||||
|
totalReportLines: number;
|
||||||
|
totalConciliated: number;
|
||||||
|
totalStatusDivergences: number;
|
||||||
|
totalDataDivergences: number;
|
||||||
|
totalOnlyInSystem: number;
|
||||||
|
totalOnlyInReport: number;
|
||||||
|
totalDuplicateReportLines: number;
|
||||||
|
totalDuplicateSystemLines: number;
|
||||||
|
totalInvalidRows: number;
|
||||||
|
totalUnknownStatuses: number;
|
||||||
|
totalSyncableIssues: number;
|
||||||
|
appliedIssuesCount: number;
|
||||||
|
appliedLinesCount: number;
|
||||||
|
appliedFieldsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MveAuditSnapshot {
|
||||||
|
numeroLinha?: string | null;
|
||||||
|
statusLinha?: string | null;
|
||||||
|
statusConta?: string | null;
|
||||||
|
planoLinha?: string | null;
|
||||||
|
dataAtivacao?: string | null;
|
||||||
|
terminoContrato?: string | null;
|
||||||
|
chip?: string | null;
|
||||||
|
conta?: string | null;
|
||||||
|
cnpj?: string | null;
|
||||||
|
modeloAparelho?: string | null;
|
||||||
|
fabricante?: string | null;
|
||||||
|
servicosAtivos?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MveAuditDifference {
|
||||||
|
fieldKey: string;
|
||||||
|
label: string;
|
||||||
|
systemValue?: string | null;
|
||||||
|
reportValue?: string | null;
|
||||||
|
syncable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MveAuditIssue {
|
||||||
|
id: string;
|
||||||
|
sourceRowNumber?: number | null;
|
||||||
|
numeroLinha: string;
|
||||||
|
mobileLineId?: string | null;
|
||||||
|
systemItem?: number | null;
|
||||||
|
issueType: string;
|
||||||
|
situation: string;
|
||||||
|
severity: string;
|
||||||
|
syncable: boolean;
|
||||||
|
applied: boolean;
|
||||||
|
actionSuggestion?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
systemStatus?: string | null;
|
||||||
|
reportStatus?: string | null;
|
||||||
|
systemPlan?: string | null;
|
||||||
|
reportPlan?: string | null;
|
||||||
|
systemSnapshot?: MveAuditSnapshot | null;
|
||||||
|
reportSnapshot?: MveAuditSnapshot | null;
|
||||||
|
differences: MveAuditDifference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MveAuditRun {
|
||||||
|
id: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
fileEncoding?: string | null;
|
||||||
|
status: string;
|
||||||
|
importedAtUtc: string;
|
||||||
|
appliedAtUtc?: string | null;
|
||||||
|
appliedByUserName?: string | null;
|
||||||
|
appliedByUserEmail?: string | null;
|
||||||
|
summary: MveAuditSummary;
|
||||||
|
issues: MveAuditIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyMveAuditResult {
|
||||||
|
auditRunId: string;
|
||||||
|
requestedIssues: number;
|
||||||
|
appliedIssues: number;
|
||||||
|
updatedLines: number;
|
||||||
|
updatedFields: number;
|
||||||
|
skippedIssues: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MveAuditService {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private currentRun: MveAuditRun | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpClient) {
|
||||||
|
const apiBase = buildApiBaseUrl(environment.apiUrl);
|
||||||
|
this.baseUrl = `${apiBase}/mve-audit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(file: File): Observable<MveAuditRun> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return this.http
|
||||||
|
.post<MveAuditRun>(`${this.baseUrl}/preview`, form)
|
||||||
|
.pipe(tap((run) => this.cacheRun(run)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Observable<MveAuditRun> {
|
||||||
|
return this.http
|
||||||
|
.get<MveAuditRun>(`${this.baseUrl}/${id}`)
|
||||||
|
.pipe(tap((run) => this.cacheRun(run)));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatest(): Observable<MveAuditRun> {
|
||||||
|
return this.http
|
||||||
|
.get<MveAuditRun>(`${this.baseUrl}/latest`)
|
||||||
|
.pipe(tap((run) => this.cacheRun(run)));
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(runId: string, issueIds?: string[]): Observable<ApplyMveAuditResult> {
|
||||||
|
return this.http.post<ApplyMveAuditResult>(`${this.baseUrl}/${runId}/apply`, {
|
||||||
|
issueIds: issueIds && issueIds.length > 0 ? issueIds : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedRun(): MveAuditRun | null {
|
||||||
|
return this.currentRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedRunId(): string | null {
|
||||||
|
if (this.currentRun?.id) {
|
||||||
|
return this.currentRun.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionStorage.getItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreCachedRun(): Observable<MveAuditRun | null> {
|
||||||
|
if (this.currentRun) {
|
||||||
|
return of(this.currentRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = this.getCachedRunId();
|
||||||
|
if (!runId) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getById(runId).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.clearCache();
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.currentRun = null;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheRun(run: MveAuditRun | null | undefined): void {
|
||||||
|
if (!run?.id) {
|
||||||
|
this.clearCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentRun = run;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY, run.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
mergeAccountCompaniesWithDefaults,
|
||||||
|
normalizeConta,
|
||||||
|
resolveEmpresaByConta,
|
||||||
|
resolveOperadoraContext,
|
||||||
|
sameConta,
|
||||||
|
} from './account-operator.util';
|
||||||
|
|
||||||
|
describe('account-operator.util', () => {
|
||||||
|
it('normaliza contas removendo zeros a esquerda', () => {
|
||||||
|
expect(normalizeConta('0455371844')).toBe('455371844');
|
||||||
|
expect(normalizeConta('000187890982')).toBe('187890982');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compara contas normalizadas', () => {
|
||||||
|
expect(sameConta('0435288088', '435288088')).toBeTrue();
|
||||||
|
expect(sameConta('172593311', '172593840')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve empresa por conta com regras deterministicas obrigatorias', () => {
|
||||||
|
expect(resolveEmpresaByConta('455371844', [])).toBe('VIVO MACROPHONY');
|
||||||
|
expect(resolveEmpresaByConta('460161507', [])).toBe('VIVO MACROPHONY');
|
||||||
|
expect(resolveEmpresaByConta('187890982', [])).toBe('CLARO LINE MÓVEL');
|
||||||
|
expect(resolveEmpresaByConta('TIM', [])).toBe('TIM LINE MÓVEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mescla lista da API com defaults sem perder contas obrigatorias', () => {
|
||||||
|
const merged = mergeAccountCompaniesWithDefaults([
|
||||||
|
{ empresa: 'VIVO MACROPHONY', contas: ['0430237019'] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const vivo = merged.find((group) => group.empresa === 'VIVO MACROPHONY');
|
||||||
|
const contas = (vivo?.contas ?? []).map((value) => normalizeConta(value));
|
||||||
|
|
||||||
|
expect(contas).toContain(normalizeConta('455371844'));
|
||||||
|
expect(contas).toContain(normalizeConta('460161507'));
|
||||||
|
expect(contas).toContain(normalizeConta('0430237019'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifica operadora e grupo da vivo por contexto', () => {
|
||||||
|
const vivo = resolveOperadoraContext({
|
||||||
|
conta: '455371844',
|
||||||
|
accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
});
|
||||||
|
expect(vivo.operadora).toBe('VIVO');
|
||||||
|
expect(vivo.vivoEmpresaGrupo).toBe('MACROPHONY');
|
||||||
|
|
||||||
|
const claro = resolveOperadoraContext({
|
||||||
|
conta: '187890982',
|
||||||
|
accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
});
|
||||||
|
expect(claro.operadora).toBe('CLARO');
|
||||||
|
expect(claro.vivoEmpresaGrupo).toBeNull();
|
||||||
|
|
||||||
|
const tim = resolveOperadoraContext({
|
||||||
|
empresaConta: 'TIM LINE MÓVEL',
|
||||||
|
accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
});
|
||||||
|
expect(tim.operadora).toBe('TIM');
|
||||||
|
|
||||||
|
const timByConta = resolveOperadoraContext({
|
||||||
|
conta: 'TIM',
|
||||||
|
accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
});
|
||||||
|
expect(timByConta.operadora).toBe('TIM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioriza mapeamento deterministico por conta mesmo com empresa da linha divergente', () => {
|
||||||
|
const vivoDeterministico = resolveOperadoraContext({
|
||||||
|
conta: '455371844',
|
||||||
|
empresaConta: 'VIVO LINE MÓVEL',
|
||||||
|
accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vivoDeterministico.operadora).toBe('VIVO');
|
||||||
|
expect(vivoDeterministico.empresaConta).toBe('VIVO MACROPHONY');
|
||||||
|
expect(vivoDeterministico.vivoEmpresaGrupo).toBe('MACROPHONY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { normalizeAccentInsensitive } from './text-normalization.util';
|
||||||
|
|
||||||
|
export type OperadoraNome = 'VIVO' | 'CLARO' | 'TIM' | 'OUTRA';
|
||||||
|
export type OperadoraFiltro = 'TODOS' | 'VIVO' | 'CLARO' | 'TIM';
|
||||||
|
export type VivoEmpresaGrupo = 'MACROPHONY' | 'LINE MOVEL' | 'OUTRA';
|
||||||
|
|
||||||
|
export interface AccountCompanyOption {
|
||||||
|
empresa: string;
|
||||||
|
contas: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperadoraResolution {
|
||||||
|
operadora: OperadoraNome;
|
||||||
|
empresaConta: string;
|
||||||
|
vivoEmpresaGrupo: VivoEmpresaGrupo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ACCOUNT_COMPANIES: AccountCompanyOption[] = [
|
||||||
|
{ empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840', '187890982'] },
|
||||||
|
{ empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844', '455371844', '460161507'] },
|
||||||
|
{ empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] },
|
||||||
|
{ empresa: 'TIM LINE MÓVEL', contas: ['TIM'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_EMPRESA_BY_CONTA = buildDefaultEmpresaByConta();
|
||||||
|
|
||||||
|
function buildDefaultEmpresaByConta(): Map<string, string> {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
|
||||||
|
DEFAULT_ACCOUNT_COMPANIES.forEach((group) => {
|
||||||
|
(group.contas ?? []).forEach((conta) => {
|
||||||
|
const normalized = normalizeConta(conta);
|
||||||
|
if (!normalized) return;
|
||||||
|
result.set(normalized, group.empresa);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmpresaKey(value: unknown): string {
|
||||||
|
return normalizeAccentInsensitive(value, 'upper').replace(/[^A-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContas(contas: unknown): string[] {
|
||||||
|
if (!Array.isArray(contas)) return [];
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
contas.forEach((value) => {
|
||||||
|
const trimmed = String(value ?? '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const normalized = normalizeConta(trimmed);
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
result.push(trimmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConta(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(raw)) {
|
||||||
|
return normalizeAccentInsensitive(raw, 'upper');
|
||||||
|
}
|
||||||
|
|
||||||
|
const noLeadingZero = raw.replace(/^0+/, '');
|
||||||
|
return noLeadingZero || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameConta(a: unknown, b: unknown): boolean {
|
||||||
|
return normalizeConta(a) === normalizeConta(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAccountCompaniesWithDefaults(
|
||||||
|
source: AccountCompanyOption[] | null | undefined
|
||||||
|
): AccountCompanyOption[] {
|
||||||
|
const merged = new Map<string, { empresa: string; contas: string[] }>();
|
||||||
|
const contaSeenByEmpresa = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
const addGroup = (empresaRaw: unknown, contasRaw: unknown) => {
|
||||||
|
const empresa = String(empresaRaw ?? '').trim();
|
||||||
|
if (!empresa) return;
|
||||||
|
|
||||||
|
const key = normalizeEmpresaKey(empresa);
|
||||||
|
const contas = normalizeContas(contasRaw);
|
||||||
|
|
||||||
|
if (!merged.has(key)) {
|
||||||
|
merged.set(key, { empresa, contas: [] });
|
||||||
|
contaSeenByEmpresa.set(key, new Set<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = merged.get(key);
|
||||||
|
const seen = contaSeenByEmpresa.get(key);
|
||||||
|
if (!record || !seen) return;
|
||||||
|
|
||||||
|
contas.forEach((conta) => {
|
||||||
|
const normalized = normalizeConta(conta);
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
record.contas.push(conta);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
(source ?? []).forEach((group) => addGroup(group?.empresa, group?.contas));
|
||||||
|
DEFAULT_ACCOUNT_COMPANIES.forEach((group) => addGroup(group.empresa, group.contas));
|
||||||
|
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEmpresaByConta(
|
||||||
|
conta: unknown,
|
||||||
|
accountCompanies: AccountCompanyOption[] | null | undefined
|
||||||
|
): string {
|
||||||
|
const target = normalizeConta(conta);
|
||||||
|
if (!target) return '';
|
||||||
|
|
||||||
|
const deterministic = DEFAULT_EMPRESA_BY_CONTA.get(target);
|
||||||
|
if (deterministic) return deterministic;
|
||||||
|
|
||||||
|
const found = (accountCompanies ?? []).find((group) =>
|
||||||
|
(group.contas ?? []).some((candidate) => sameConta(candidate, target))
|
||||||
|
);
|
||||||
|
return found?.empresa ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOperadoraByEmpresa(empresa: unknown): OperadoraNome {
|
||||||
|
const normalized = normalizeEmpresaKey(empresa);
|
||||||
|
if (!normalized) return 'OUTRA';
|
||||||
|
if (normalized.includes('CLARO')) return 'CLARO';
|
||||||
|
if (normalized.includes('TIM')) return 'TIM';
|
||||||
|
if (normalized.includes('VIVO') || normalized.includes('MACROPHONY')) return 'VIVO';
|
||||||
|
return 'OUTRA';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVivoEmpresaGrupo(empresa: unknown): VivoEmpresaGrupo {
|
||||||
|
const normalized = normalizeEmpresaKey(empresa);
|
||||||
|
if (!normalized) return 'OUTRA';
|
||||||
|
if (normalized.includes('MACROPHONY')) return 'MACROPHONY';
|
||||||
|
if (normalized.includes('LINEMOVEL') || normalized.includes('LINEMOV')) return 'LINE MOVEL';
|
||||||
|
return 'OUTRA';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOperadoraContext(input: {
|
||||||
|
conta?: unknown;
|
||||||
|
empresaConta?: unknown;
|
||||||
|
accountCompanies?: AccountCompanyOption[] | null;
|
||||||
|
}): OperadoraResolution {
|
||||||
|
const contaRaw = String(input.conta ?? '').trim();
|
||||||
|
const contaEmpresaRaw = String(input.empresaConta ?? '').trim();
|
||||||
|
const empresaFromConta = resolveEmpresaByConta(input.conta, input.accountCompanies);
|
||||||
|
// Regras por conta (determinísticas) têm prioridade sobre texto livre da linha.
|
||||||
|
const empresaConta = empresaFromConta || contaEmpresaRaw;
|
||||||
|
|
||||||
|
let operadora = resolveOperadoraByEmpresa(empresaConta);
|
||||||
|
if (operadora === 'OUTRA' && empresaFromConta) {
|
||||||
|
operadora = resolveOperadoraByEmpresa(empresaFromConta);
|
||||||
|
}
|
||||||
|
if (operadora === 'OUTRA' && contaRaw) {
|
||||||
|
operadora = resolveOperadoraByEmpresa(contaRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vivoEmpresaGrupo = operadora === 'VIVO'
|
||||||
|
? resolveVivoEmpresaGrupo(empresaConta || empresaFromConta || contaRaw)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
operadora,
|
||||||
|
empresaConta: empresaConta || '',
|
||||||
|
vivoEmpresaGrupo,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue