feat: novas implementações, ajustes páginas

This commit is contained in:
Eduardo Lopes 2026-03-09 14:44:22 -03:00
parent a7cb6d5d95
commit 43bf611122
20 changed files with 2655 additions and 67 deletions

View File

@ -51,8 +51,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "20kB",
"maximumError": "40kB"
"maximumWarning": "35kB",
"maximumError": "60kB"
}
],
"outputHashing": "all"

View File

@ -24,6 +24,7 @@ import { HistoricoLinhas } from './pages/historico-linhas/historico-linhas';
import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
import { MveAuditoriaPage } from './pages/mve-auditoria/mve-auditoria';
export const routes: Routes = [
{ path: '', component: Home },
@ -43,6 +44,7 @@ export const routes: Routes = [
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
{ path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' },
{ 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: 'system/fornecer-usuario',

View File

@ -29,6 +29,7 @@ export class AppComponent {
// ✅ rotas internas (LOGADO) que devem esconder footer
private readonly loggedPrefixes = [
'/geral',
'/auditoria-mve',
'/solicitacoes-linhas',
'/mureg',
'/faturamento',

View File

@ -216,22 +216,24 @@
</ng-container>
<ng-template #publicHeader>
<a routerLink="/" class="logo-area">
<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__line">Line</div>
<div class="lg-wordmark__movel">Gestão</div>
</div>
</a>
<nav class="nav-links">
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
</nav>
<div class="header-actions">
<a routerLink="/login" class="btn-login-header">
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
<div class="public-header-layout">
<a routerLink="/" class="logo-area">
<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__line">Line</div>
<div class="lg-wordmark__movel">Gestão</div>
</div>
</a>
<nav class="nav-links">
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
</nav>
<div class="header-actions public-header-actions">
<a routerLink="/login" class="btn-login-header">
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
</a>
</div>
</div>
</ng-template>
@ -526,7 +528,7 @@
</a>
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="side-menu-body">
<div class="side-menu-body custom-scroll">
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</a>
@ -536,6 +538,9 @@
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim"></i> <span>Geral</span>
</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()">
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
</a>

View File

@ -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;
&: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; }
.btn-login-header {
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 {
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 {
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; }
@ -1072,28 +1103,35 @@ $logo-secondary-grey: #757575;
--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-inner > .logo-area {
.public-header-layout > .logo-area {
flex: 1 1 auto;
min-width: 0;
}
.header-inner > .logo-area .lg-wordmark {
.public-header-layout > .logo-area .lg-wordmark {
display: block;
white-space: nowrap;
}
.header-inner > .logo-area .lg-wordmark {
.public-header-layout > .logo-area .lg-wordmark {
--scale: 0.19;
}
.header-inner > .header-actions {
margin-left: auto;
.public-header-layout > .header-actions {
margin-left: 0;
flex: 0 0 auto;
justify-content: flex-end;
}
.header-inner > .header-actions .btn-login-header {
.public-header-layout > .header-actions .btn-login-header {
padding: 7px 10px;
gap: 4px;
font-size: 12px;
@ -1404,20 +1442,20 @@ $logo-secondary-grey: #757575;
}
@media (max-width: 420px) {
.header-inner > .logo-area {
.public-header-layout > .logo-area {
gap: 5px;
}
.header-inner > .logo-area .logo-symbol {
.public-header-layout > .logo-area .logo-symbol {
width: 32px;
height: 32px;
}
.header-inner > .logo-area .lg-wordmark {
.public-header-layout > .logo-area .lg-wordmark {
--scale: 0.18;
}
.header-inner > .header-actions .btn-login-header {
.public-header-layout > .header-actions .btn-login-header {
padding: 6px 8px;
font-size: 11px;
gap: 3px;

View File

@ -39,6 +39,7 @@ export class Header implements AfterViewInit, OnDestroy {
isFinanceiro = false;
canViewAll = false;
canViewFinancialPages = false;
canViewMveAudit = false;
clientTenantDisplayName = '';
private clientTenantNameTenantId: string | null = null;
private readonly baseApi: string;
@ -100,6 +101,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/historico',
'/historico-linhas',
'/solicitacoes',
'/auditoria-mve',
'/perfil',
'/system',
];
@ -235,6 +237,7 @@ export class Header implements AfterViewInit, OnDestroy {
this.isFinanceiro = isFinanceiro;
this.canViewAll = isSysAdmin || isGestor || isFinanceiro;
this.canViewFinancialPages = isSysAdmin || isFinanceiro;
this.canViewMveAudit = isSysAdmin || isGestor;
if (!this.isClientHeader) {
this.clientTenantDisplayName = '';

View File

@ -125,6 +125,7 @@
[disabled]="!createModel.contaEmpresa"
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="createModel.conta"
(ngModelChange)="onContaChange(false)"
></app-select>
</div>
@ -736,7 +737,7 @@
[disabled]="!activeLine.contaEmpresa"
[placeholder]="activeLine.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="activeLine.conta"
(ngModelChange)="onBatchLineDetailsChange()"
(ngModelChange)="onBatchContaChange(activeLine)"
></app-select>
</div>
@ -1389,6 +1390,10 @@
<span class="lbl">Plano Contratado</span>
<span class="val fw-bold text-brand">{{ detailData.planoContrato || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Empresa (Conta)</span>
<span class="val">{{ detailData.contaEmpresa || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Conta</span>
<span class="val">{{ detailData.conta || '-' }}</span>
@ -1616,8 +1621,8 @@
<div class="box-body">
<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>Empresa (Conta)</label><app-select class="form-select" size="sm" [options]="contaEmpresaOptionsForEdit" [(ngModel)]="editModel.contaEmpresa" (ngModelChange)="onContaEmpresaChange(true)"></app-select></div>
<div class="form-field"><label>Conta</label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta"></app-select></div>
<div class="form-field"><label>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 <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>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>

View File

@ -866,3 +866,421 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
.summary-pill { font-size: 0.72rem; }
.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%;
}
}

View File

@ -25,12 +25,19 @@ import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
import { TableExportService } from '../../services/table-export.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import {
MveAuditService,
type ApplyMveAuditResult,
type MveAuditIssue,
type MveAuditRun,
} from '../../services/mve-audit.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
import {
buildPageNumbers,
clampPage,
computePageEnd,
computePageStart,
computeTotalPages
} from '../../utils/pagination.util';
@ -98,6 +105,14 @@ interface ApiLineList {
vivoGestaoDispositivo?: number | null;
}
interface SmartSearchTargetResolution {
client: string;
skilFilter: 'ALL' | 'PF' | 'PJ' | 'RESERVA';
statusFilter: 'ALL' | 'BLOCKED';
blockedStatusMode: BlockedStatusMode;
requiresFilterAdjustment: boolean;
}
interface ApiLineDetail {
id: string;
item: number;
@ -306,6 +321,25 @@ interface BatchLineStatusUpdateResultDto {
items: BatchLineStatusUpdateItemResultDto[];
}
type MveAuditFilterMode =
| 'ALL'
| 'STATUS'
| 'DATA'
| 'ONLY_IN_SYSTEM'
| 'ONLY_IN_REPORT'
| 'DUPLICATES'
| 'INVALID'
| 'UNKNOWN';
type MveAuditApplyMode = 'ALL_SYNCABLE' | 'FILTERED_SYNCABLE';
interface MveApplySelectionSummary {
totalIssues: number;
totalStatusIssues: number;
totalDataIssues: number;
totalAffectedLines: number;
}
@Component({
standalone: true,
@ -337,7 +371,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private route: ActivatedRoute,
private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService,
private tableExportService: TableExportService
private tableExportService: TableExportService,
private mveAuditService: MveAuditService
) {}
private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
@ -429,6 +464,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
batchStatusType = '';
batchStatusUsuario = '';
batchStatusLastResult: BatchLineStatusUpdateResultDto | null = null;
mveAuditOpen = false;
mveAuditProcessing = false;
mveAuditApplying = false;
mveAuditFile: File | null = null;
mveAuditResult: MveAuditRun | null = null;
mveAuditError = '';
mveAuditFilter: MveAuditFilterMode = 'ALL';
mveAuditSearchTerm = '';
mveAuditPage = 1;
mveAuditPageSize = 10;
mveAuditPageSizeOptions = [10, 25, 50, 100];
mveAuditApplyConfirmOpen = false;
mveAuditApplyMode: MveAuditApplyMode = 'ALL_SYNCABLE';
mveAuditApplyLastResult: ApplyMveAuditResult | null = null;
detailData: any = null;
financeData: any = null;
@ -755,6 +804,112 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
.map((key) => this.additionalServiceOptions.find((x) => x.key === key)?.label ?? key);
}
get hasMveAuditResult(): boolean {
return !!this.mveAuditResult;
}
get filteredMveAuditIssues(): MveAuditIssue[] {
const source = this.mveAuditResult?.issues ?? [];
const search = this.normalizeMveSearchTerm(this.mveAuditSearchTerm);
return source.filter((issue) => {
if (!this.matchesMveIssueFilter(issue)) {
return false;
}
if (!search) {
return true;
}
const haystack = [
issue.numeroLinha,
issue.issueType,
issue.situation,
issue.systemStatus,
issue.reportStatus,
issue.systemPlan,
issue.reportPlan,
issue.actionSuggestion,
issue.notes,
...(issue.differences ?? []).flatMap((diff) => [diff.label, diff.systemValue, diff.reportValue]),
]
.map((value) => this.normalizeMveSearchTerm(value))
.join(' ');
return haystack.includes(search);
});
}
get mveAuditTotalPages(): number {
return computeTotalPages(this.filteredMveAuditIssues.length, this.mveAuditPageSize);
}
get mveAuditPageNumbers(): number[] {
return buildPageNumbers(this.mveAuditPage, this.mveAuditTotalPages);
}
get pagedMveAuditIssues(): MveAuditIssue[] {
const start = computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
if (start <= 0) {
return this.filteredMveAuditIssues.slice(0, this.mveAuditPageSize);
}
const offset = start - 1;
return this.filteredMveAuditIssues.slice(offset, offset + this.mveAuditPageSize);
}
get mveAuditPageStart(): number {
return computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
}
get mveAuditPageEnd(): number {
return computePageEnd(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
}
get allSyncableMveIssues(): MveAuditIssue[] {
return (this.mveAuditResult?.issues ?? []).filter((issue) => issue.syncable && !issue.applied);
}
get filteredSyncableMveIssues(): MveAuditIssue[] {
return this.filteredMveAuditIssues.filter((issue) => issue.syncable && !issue.applied);
}
get selectedMveApplyIssues(): MveAuditIssue[] {
return this.mveAuditApplyMode === 'FILTERED_SYNCABLE'
? this.filteredSyncableMveIssues
: this.allSyncableMveIssues;
}
get mveApplySelectionSummary(): MveApplySelectionSummary {
const selected = this.selectedMveApplyIssues;
const affectedLines = new Set<string>();
let totalStatusIssues = 0;
let totalDataIssues = 0;
for (const issue of selected) {
if (issue.mobileLineId) affectedLines.add(issue.mobileLineId);
else if (issue.numeroLinha) affectedLines.add(issue.numeroLinha);
if (this.issueHasStatusDifference(issue)) totalStatusIssues++;
if (this.issueHasDataDifference(issue)) totalDataIssues++;
}
return {
totalIssues: selected.length,
totalStatusIssues,
totalDataIssues,
totalAffectedLines: affectedLines.size,
};
}
get canSubmitMveAudit(): boolean {
return !!this.mveAuditFile && !this.mveAuditProcessing && !this.mveAuditApplying;
}
get canOpenMveApplyConfirm(): boolean {
return !this.mveAuditApplying && this.mveApplySelectionSummary.totalIssues > 0;
}
// ✅ fecha dropdown ao clicar fora
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
@ -1048,6 +1203,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
this.syncContaEmpresaSelection(this.detailData);
this.syncContaEmpresaSelection(this.financeData);
this.cdr.detectChanges();
},
error: () => {
@ -1056,6 +1213,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
this.syncContaEmpresaSelection(this.detailData);
this.syncContaEmpresaSelection(this.financeData);
}
});
}
@ -1071,7 +1230,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.createOpen ||
this.reservaTransferOpen ||
this.moveToReservaOpen ||
this.batchStatusOpen
this.batchStatusOpen ||
this.mveAuditOpen
);
}
@ -1101,6 +1261,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
this.batchStatusOpen = false;
this.mveAuditOpen = false;
this.mveAuditApplyConfirmOpen = false;
this.detailData = null;
this.financeData = null;
@ -1125,6 +1287,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.moveToReservaLastResult = null;
this.batchStatusLastResult = null;
this.batchStatusUsuario = '';
this.mveAuditFile = null;
this.mveAuditProcessing = false;
this.mveAuditApplying = false;
this.mveAuditResult = null;
this.mveAuditError = '';
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
this.mveAuditApplyMode = 'ALL_SYNCABLE';
this.mveAuditApplyLastResult = null;
// Limpa overlays/locks residuais
this.cleanupModalArtifacts();
@ -1158,7 +1330,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const t = (term ?? '').trim();
if (!t) return false;
const digits = t.replace(/\D/g, '');
const digits = this.normalizeDigits(t);
if (!digits) return false;
if (digits.length >= 17) return true; // ICCID
@ -1167,31 +1339,143 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return false;
}
private resolveSearchToClient(term: string): Promise<string | null> {
private normalizeDigits(value: unknown): string {
return String(value ?? '').replace(/\D/g, '');
}
private resolveSkilFilterFromLine(skil: unknown): 'ALL' | 'PF' | 'PJ' | 'RESERVA' {
const parsed = this.parseQuerySkilFilter(skil);
return parsed ?? 'ALL';
}
private findBestSpecificSearchMatch(items: ApiLineList[], term: string): ApiLineList | null {
if (!Array.isArray(items) || items.length === 0) return null;
const digits = this.normalizeDigits(term);
if (!digits) return null;
const isIccidSearch = digits.length >= 17;
const exactMatches = items.filter((item) => {
const lineDigits = this.normalizeDigits(item?.linha);
const chipDigits = this.normalizeDigits(item?.chip);
return lineDigits === digits || chipDigits === digits;
});
if (exactMatches.length > 0) return exactMatches[0];
const compatibleMatches = items.filter((item) => {
const lineDigits = this.normalizeDigits(item?.linha);
const chipDigits = this.normalizeDigits(item?.chip);
if (isIccidSearch) {
return !!chipDigits && (
chipDigits.endsWith(digits) ||
digits.endsWith(chipDigits) ||
chipDigits.includes(digits)
);
}
return !!lineDigits && (
lineDigits.endsWith(digits) ||
digits.endsWith(lineDigits) ||
lineDigits.includes(digits)
);
});
return compatibleMatches[0] ?? items[0] ?? null;
}
private async findSpecificSearchMatch(
term: string,
options?: {
ignoreCurrentFilters?: boolean;
skilFilter?: 'ALL' | 'PF' | 'PJ' | 'RESERVA';
}
): Promise<ApiLineList | null> {
const s = (term ?? '').trim();
if (!s) return Promise.resolve(null);
if (!s) return null;
const pageSize = this.hasClientSideFiltersApplied ? '500' : '1';
let params = new HttpParams().set('page', '1').set('pageSize', pageSize).set('search', s);
params = this.applyBaseFilters(params);
let params = new HttpParams()
.set('page', '1')
.set('pageSize', options?.ignoreCurrentFilters ? '200' : '500')
.set('search', s);
if (this.selectedClients.length > 0) {
if (!options?.ignoreCurrentFilters) {
params = this.applyBaseFilters(params);
this.selectedClients.forEach((c) => (params = params.append('client', c)));
} else if (options?.skilFilter === 'PF') {
params = params.set('skil', 'PESSOA FÍSICA');
} else if (options?.skilFilter === 'PJ') {
params = params.set('skil', 'PESSOA JURÍDICA');
} else if (options?.skilFilter === 'RESERVA') {
params = params.set('skil', 'RESERVA');
}
return new Promise((resolve) => {
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) }).subscribe({
next: (res) => {
const source = this.hasClientSideFiltersApplied
? this.applyAdditionalFiltersClientSide(res.items ?? [])
: (res.items ?? []);
const first = source[0];
const client = (first?.cliente ?? '').trim();
resolve(client || null);
},
error: () => resolve(null)
});
try {
const response = await firstValueFrom(
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params: this.withNoCache(params) })
);
let items = response?.items ?? [];
if (!options?.ignoreCurrentFilters && this.hasClientSideFiltersApplied) {
items = this.applyAdditionalFiltersClientSide(items);
}
return this.findBestSpecificSearchMatch(items, s);
} catch {
return null;
}
}
private buildSmartSearchTarget(
line: ApiLineList,
requiresFilterAdjustment: boolean
): SmartSearchTargetResolution | null {
if (!line) return null;
const skilFilter = this.resolveSkilFilterFromLine(line?.skil);
const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL';
const client = ((line?.cliente ?? '').toString().trim()) || (skilFilter === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE');
return {
client,
skilFilter,
statusFilter: blockedStatusMode === 'ALL' ? 'ALL' : 'BLOCKED',
blockedStatusMode,
requiresFilterAdjustment
};
}
private async resolveSmartSearchTarget(term: string): Promise<SmartSearchTargetResolution | null> {
const currentContextMatch = await this.findSpecificSearchMatch(term);
if (currentContextMatch) {
return this.buildSmartSearchTarget(currentContextMatch, false);
}
const globalMatch = await this.findSpecificSearchMatch(term, { ignoreCurrentFilters: true });
if (globalMatch) {
return this.buildSmartSearchTarget(globalMatch, true);
}
const reservaMatch = await this.findSpecificSearchMatch(term, {
ignoreCurrentFilters: true,
skilFilter: 'RESERVA'
});
if (reservaMatch) {
return this.buildSmartSearchTarget(reservaMatch, true);
}
return null;
}
private applySmartSearchFilters(target: SmartSearchTargetResolution): void {
this.filterSkil = target.skilFilter;
this.filterStatus = target.statusFilter;
this.blockedStatusMode = target.statusFilter === 'BLOCKED' ? target.blockedStatusMode : 'ALL';
this.selectedClients = [];
this.clientSearchTerm = '';
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
}
onSearch() {
@ -1215,19 +1499,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
if (this.isSpecificSearchTerm(term)) {
const client = await this.resolveSearchToClient(term);
const target = await this.resolveSmartSearchTarget(term);
if (requestVersion !== this.searchRequestVersion) return;
if (client) {
this.searchResolvedClient = client;
if (target) {
if (target.requiresFilterAdjustment) {
this.applySmartSearchFilters(target);
if (!this.isClientRestricted) {
this.loadClients();
}
}
this.searchResolvedClient = target.client;
this.loadKpis();
await this.loadOnlyThisClientGroup(client);
await this.loadOnlyThisClientGroup(target.client);
if (requestVersion !== this.searchRequestVersion) return;
this.expandedGroup = client;
this.fetchGroupLines(client, term);
this.expandedGroup = target.client;
this.fetchGroupLines(target.client, term);
return;
}
}
@ -2449,6 +2740,233 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
});
}
async openMveAuditModal() {
if (!this.canManageLines) {
await this.showToast('Você não tem permissão para auditar linhas com o relatório MVE.');
return;
}
this.mveAuditOpen = true;
this.mveAuditFile = null;
this.mveAuditResult = null;
this.mveAuditError = '';
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
this.mveAuditApplyMode = 'ALL_SYNCABLE';
this.mveAuditApplyConfirmOpen = false;
this.mveAuditApplyLastResult = null;
this.cdr.detectChanges();
}
onMveAuditFileSelected(event: Event) {
const file = (event.target as HTMLInputElement | null)?.files?.[0] ?? null;
this.mveAuditError = '';
this.mveAuditApplyLastResult = null;
if (!file) {
this.mveAuditFile = null;
return;
}
if (!file.name.toLowerCase().endsWith('.csv')) {
this.mveAuditFile = null;
this.mveAuditError = 'Selecione um arquivo CSV exportado do MVE da Vivo.';
return;
}
if (file.size <= 0) {
this.mveAuditFile = null;
this.mveAuditError = 'O arquivo selecionado está vazio.';
return;
}
if (file.size > 20 * 1024 * 1024) {
this.mveAuditFile = null;
this.mveAuditError = 'O arquivo do MVE excede o limite de 20 MB.';
return;
}
this.mveAuditFile = file;
}
clearMveAuditFile() {
this.mveAuditFile = null;
this.mveAuditError = '';
}
async submitMveAudit() {
if (!this.canSubmitMveAudit || !this.mveAuditFile) {
return;
}
this.mveAuditProcessing = true;
this.mveAuditError = '';
this.mveAuditResult = null;
this.mveAuditApplyLastResult = null;
try {
this.mveAuditResult = await firstValueFrom(this.mveAuditService.preview(this.mveAuditFile));
this.mveAuditFilter = 'ALL';
this.mveAuditSearchTerm = '';
this.mveAuditPage = 1;
await this.showToast('Auditoria MVE processada com sucesso.');
} catch (err) {
this.mveAuditError = this.extractHttpMessage(err, 'Não foi possível processar o relatório MVE.');
} finally {
this.mveAuditProcessing = false;
this.cdr.detectChanges();
}
}
setMveAuditFilter(filter: MveAuditFilterMode) {
this.mveAuditFilter = filter;
this.mveAuditPage = 1;
}
onMveAuditSearchChange() {
this.mveAuditPage = 1;
}
onMveAuditPageSizeChange() {
this.mveAuditPage = 1;
}
goToMveAuditPage(page: number) {
this.mveAuditPage = clampPage(page, this.mveAuditTotalPages);
}
openMveApplyConfirm(mode: MveAuditApplyMode) {
if (!this.hasMveAuditResult) return;
this.mveAuditApplyMode = mode;
if (!this.canOpenMveApplyConfirm) return;
this.mveAuditApplyConfirmOpen = true;
}
closeMveApplyConfirm() {
this.mveAuditApplyConfirmOpen = false;
}
async confirmMveApply() {
if (!this.mveAuditResult || !this.canOpenMveApplyConfirm) {
return;
}
const selectedIssues = this.selectedMveApplyIssues;
this.mveAuditApplying = true;
try {
const result = await firstValueFrom(
this.mveAuditService.apply(
this.mveAuditResult.id,
this.mveAuditApplyMode === 'FILTERED_SYNCABLE' ? selectedIssues.map((issue) => issue.id) : undefined
)
);
this.mveAuditApplyLastResult = result;
this.mveAuditResult = await firstValueFrom(this.mveAuditService.getById(this.mveAuditResult.id));
this.mveAuditApplyConfirmOpen = false;
const label =
result.updatedLines > 0
? `${result.updatedLines} linha(s) atualizada(s) com base no MVE.`
: 'Nenhuma linha precisou ser alterada com a sincronização MVE.';
await this.showToast(label);
this.refreshData({ keepCurrentPage: true });
} catch (err) {
await this.showToast(this.extractHttpMessage(err, 'Não foi possível aplicar a sincronização MVE.'));
} finally {
this.mveAuditApplying = false;
this.cdr.detectChanges();
}
}
async exportMveAuditIssues() {
if (!this.hasMveAuditResult || this.filteredMveAuditIssues.length === 0) {
await this.showToast('Não há inconsistências filtradas para exportar.');
return;
}
const headers = [
'Numero da linha',
'Item sistema',
'Situacao',
'Tipo',
'Status sistema',
'Status relatorio',
'Plano sistema',
'Plano relatorio',
'Diferencas',
'Acao sugerida',
'Observacoes',
];
const rows = this.filteredMveAuditIssues.map((issue) =>
[
issue.numeroLinha || '',
issue.systemItem != null ? String(issue.systemItem) : '',
issue.situation || '',
issue.issueType || '',
issue.systemStatus || '',
issue.reportStatus || '',
issue.systemPlan || '',
issue.reportPlan || '',
this.describeMveDifferences(issue),
issue.actionSuggestion || '',
issue.notes || '',
]
.map((value) => this.escapeCsvValue(value))
.join(';')
);
const content = `${headers.join(';')}\n${rows.join('\n')}`;
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
const timestamp = this.tableExportService.buildTimestamp();
this.downloadBlob(blob, `mve_auditoria_${timestamp}.csv`);
await this.showToast(`CSV exportado com ${rows.length} inconsistência(s).`);
}
describeMveDifferences(issue: MveAuditIssue): string {
const differences = issue.differences ?? [];
if (!differences.length) {
return issue.notes ?? '-';
}
return differences
.map((diff) => `${diff.label}: ${diff.systemValue ?? '-'} -> ${diff.reportValue ?? '-'}`)
.join(' | ');
}
getMveIssueTagClass(issue: MveAuditIssue): string {
switch (issue.issueType) {
case 'STATUS_DIVERGENCE':
case 'STATUS_AND_DATA_DIVERGENCE':
return 'status';
case 'DATA_DIVERGENCE':
return 'data';
case 'ONLY_IN_SYSTEM':
return 'system';
case 'ONLY_IN_REPORT':
return 'report';
case 'DUPLICATE_REPORT':
case 'DUPLICATE_SYSTEM':
return 'duplicate';
case 'INVALID_ROW':
case 'UNKNOWN_STATUS':
return 'warning';
default:
return 'neutral';
}
}
getMveSeverityClass(severity: string | null | undefined): string {
const normalized = (severity ?? '').toString().trim().toUpperCase();
if (normalized === 'HIGH') return 'critical';
if (normalized === 'MEDIUM') return 'medium';
if (normalized === 'WARNING') return 'warning';
return 'neutral';
}
private getById(id: string, cb: (d: any) => void) {
this.http.get(`${this.apiBase}/${id}`).subscribe({
next: cb,
@ -2461,7 +2979,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.detailData = null;
this.cdr.detectChanges();
this.getById(r.id, (d) => {
this.detailData = d;
this.detailData = {
...d,
contaEmpresa: this.findEmpresaByConta(d?.conta)
};
this.syncContaEmpresaSelection(this.detailData);
this.cdr.detectChanges();
});
}
@ -2590,6 +3112,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
franquiaLine: franquiaLineAtual
};
} else {
const contaEmpresaValidationMessage = this.validateContaEmpresaBinding(this.editModel);
if (contaEmpresaValidationMessage) {
this.editSaving = false;
await this.showToast(contaEmpresaValidationMessage);
return;
}
this.calculateFinancials(this.editModel);
const {
@ -3676,6 +4205,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!hasMatch) model.conta = '';
}
onContaChange(isEdit: boolean) {
const model = isEdit ? this.editModel : this.createModel;
this.syncContaEmpresaSelection(model);
}
onBatchContaChange(row: any) {
this.syncContaEmpresaSelection(row);
this.onBatchLineDetailsChange();
}
onDocTypeChange() {
this.createModel.docNumber = '';
this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA';
@ -4360,6 +4899,73 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
}
private matchesMveIssueFilter(issue: MveAuditIssue): boolean {
switch (this.mveAuditFilter) {
case 'STATUS':
return this.issueHasStatusDifference(issue);
case 'DATA':
return this.issueHasDataDifference(issue);
case 'ONLY_IN_SYSTEM':
return issue.issueType === 'ONLY_IN_SYSTEM';
case 'ONLY_IN_REPORT':
return issue.issueType === 'ONLY_IN_REPORT';
case 'DUPLICATES':
return issue.issueType === 'DUPLICATE_REPORT' || issue.issueType === 'DUPLICATE_SYSTEM';
case 'INVALID':
return issue.issueType === 'INVALID_ROW';
case 'UNKNOWN':
return issue.issueType === 'UNKNOWN_STATUS';
default:
return true;
}
}
private issueHasStatusDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status');
}
private issueHasDataDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).some(
(difference) => difference.fieldKey !== 'status' && difference.syncable
);
}
private normalizeMveSearchTerm(value: unknown): string {
return (value ?? '')
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
private escapeCsvValue(value: unknown): string {
const text = (value ?? '').toString().replace(/"/g, '""');
return `"${text}"`;
}
private downloadBlob(blob: Blob, fileName: string) {
if (!isPlatformBrowser(this.platformId)) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
private extractHttpMessage(error: unknown, fallbackMessage: string): string {
const httpError = error as HttpErrorResponse | null;
return (
(httpError?.error as { message?: string } | null)?.message ||
httpError?.message ||
fallbackMessage
);
}
formatMoney(v: any): string {
if (v == null || Number.isNaN(v)) return '-';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v);
@ -4563,9 +5169,46 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private syncContaEmpresaSelection(model: any) {
if (!model) return;
const contaAtual = (model.conta ?? '').toString().trim();
const empresaAtual = (model.contaEmpresa ?? '').toString().trim();
if (empresaAtual) return;
if (!contaAtual) {
if (!empresaAtual) {
model.contaEmpresa = '';
}
return;
}
model.contaEmpresa = this.findEmpresaByConta(model.conta);
const empresaPorConta = this.findEmpresaByConta(contaAtual);
if (empresaPorConta) {
model.contaEmpresa = empresaPorConta;
return;
}
if (!empresaAtual) {
model.contaEmpresa = '';
}
}
private validateContaEmpresaBinding(model: any): string | null {
if (!model) return 'Dados da linha inválidos.';
this.syncContaEmpresaSelection(model);
const conta = (model.conta ?? '').toString().trim();
const contaEmpresa = (model.contaEmpresa ?? '').toString().trim();
if (!contaEmpresa) return 'Selecione a Empresa (Conta).';
if (!conta) return 'Selecione uma Conta.';
const empresaPorConta = this.findEmpresaByConta(conta);
if (!empresaPorConta) {
return 'A conta informada não está vinculada a nenhuma Empresa (Conta) cadastrada.';
}
if (empresaPorConta.localeCompare(contaEmpresa, 'pt-BR', { sensitivity: 'base' }) !== 0) {
model.contaEmpresa = empresaPorConta;
}
return null;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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
}
}
}

View File

@ -69,7 +69,7 @@
</button>
<button
type="button"
class="bulk-btn ghost"
class="bulk-btn ghost export-glass"
(click)="exportNotifications()"
[disabled]="exportLoading || filteredNotifications.length === 0"
>

View File

@ -96,6 +96,13 @@ $border: #e5e7eb;
&:disabled { opacity: 0.6; cursor: default; }
&.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) */

View File

@ -25,7 +25,7 @@
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-repeat"></i> Atualizar
</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"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>

View File

@ -208,6 +208,13 @@
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 {
color: #fff;
background: linear-gradient(145deg, #cf3131, #a91f1f);
@ -226,6 +233,12 @@
color: var(--pg-primary-strong);
}
.btn-export-glass:hover {
background: #fff;
border-color: rgba(227, 61, 207, 0.3);
color: #e33dcf;
}
.lg-modal-card {
width: min(1180px, 98vw);
max-height: 92vh;

View File

@ -128,7 +128,7 @@
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()" [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>
<span class="hide-mobile">{{ isExporting('macrophony-planos') ? 'Exportando...' : 'Exportar' }}</span>
</button>
@ -447,7 +447,7 @@
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)" [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>
<span class="hide-mobile">{{ isExporting(file) ? 'Exportando...' : 'Exportar' }}</span>
</button>

View File

@ -190,6 +190,19 @@
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 {
width: 32px;
height: 32px;

View File

@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { BehaviorSubject } from 'rxjs';
import { tap } from 'rxjs/operators';
import { MveAuditService } from './mve-audit.service';
export interface RegisterPayload {
name: string;
@ -43,7 +44,10 @@ export class AuthService {
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
private readonly rememberMeHours = 6;
constructor(private http: HttpClient) {
constructor(
private http: HttpClient,
private readonly mveAuditService: MveAuditService
) {
this.syncUserProfileFromToken();
}
@ -65,10 +69,12 @@ export class AuthService {
logout() {
if (typeof window === 'undefined') {
this.mveAuditService.clearCache();
this.userProfileSubject.next(null);
return;
}
this.mveAuditService.clearCache();
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
this.userProfileSubject.next(null);
@ -76,6 +82,7 @@ export class AuthService {
setToken(token: string, rememberMe = false) {
if (typeof window === 'undefined') return;
this.mveAuditService.clearCache();
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);

View File

@ -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);
}
}