diff --git a/angular.json b/angular.json index 6e8f7df..8e66459 100644 --- a/angular.json +++ b/angular.json @@ -51,8 +51,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "20kB", - "maximumError": "40kB" + "maximumWarning": "35kB", + "maximumError": "60kB" } ], "outputHashing": "all" diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6bb72d7..f0b8b83 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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', diff --git a/src/app/app.ts b/src/app/app.ts index 5a733b1..da02a75 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -29,6 +29,7 @@ export class AppComponent { // ✅ rotas internas (LOGADO) que devem esconder footer private readonly loggedPrefixes = [ '/geral', + '/auditoria-mve', '/solicitacoes-linhas', '/mureg', '/faturamento', diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 8df4721..00114a2 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -216,22 +216,24 @@ - - Line Gestão -
-
Line
-
Gestão
-
-
- -
- @@ -526,7 +528,7 @@
-
+
Dashboard @@ -536,6 +538,9 @@ Geral + + Auditoria MVE + Mureg diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 3ad00d1..7bf2839 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -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; diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index d18f6fe..b8f0eda 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -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 = ''; diff --git a/src/app/components/page-modals/geral-modals/geral-modals.html b/src/app/components/page-modals/geral-modals/geral-modals.html index afbae88..8ff7fb8 100644 --- a/src/app/components/page-modals/geral-modals/geral-modals.html +++ b/src/app/components/page-modals/geral-modals/geral-modals.html @@ -125,6 +125,7 @@ [disabled]="!createModel.contaEmpresa" [placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'" [(ngModel)]="createModel.conta" + (ngModelChange)="onContaChange(false)" >
@@ -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)" >
@@ -1389,6 +1390,10 @@ Plano Contratado {{ detailData.planoContrato || '-' }} +
+ Empresa (Conta) + {{ detailData.contaEmpresa || '-' }} +
Conta {{ detailData.conta || '-' }} @@ -1616,8 +1621,8 @@
-
-
+
+
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 38116a6..88e3e57 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -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%; + } +} diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 0bd8044..069e76e 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -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(); + 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 { + 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 { 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>(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>(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 { + 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; } } diff --git a/src/app/pages/mve-auditoria/mve-auditoria.html b/src/app/pages/mve-auditoria/mve-auditoria.html new file mode 100644 index 0000000..69711c1 --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.html @@ -0,0 +1,226 @@ +
+ +
+ +
+ + + + + +
+
+ + +
+
+
+ No sistema + {{ audit.summary.totalSystemLines }} +
+
+ No relatorio + {{ audit.summary.totalReportLines }} +
+
+ Sem diferenca + {{ audit.summary.totalConciliated }} +
+
+ Com diferenca + {{ audit.summary.totalStatusDivergences }} +
+
+ Prontas para atualizar + {{ syncableStatusIssues.length }} +
+
+ +
+ Só no sistema: {{ audit.summary.totalOnlyInSystem }} + Só no relatório: {{ audit.summary.totalOnlyInReport }} +
+ +
+
+ + + +
+ +
+
+ + +
+ + +
+
+ +
+ +
Nenhuma diferenca de status encontrada para o filtro atual.
+
+ +
+ + + + + + + + + + + + + + + + + + + +
NúmeroStatus no sistemaStatus no relatorioSituaçãoAção
+ {{ issue.numeroLinha || '-' }} + + {{ statusLabel(issue.systemStatus) }} + + {{ statusLabel(issue.reportStatus) }} + +
Status diferente.
+
+ Pode atualizar + Atualizada + Sem ação +
+
+ + +
+ + +
+ +
Nenhuma conferencia carregada ainda.
+ Envie o relatorio da Vivo para ver as diferencas de status e atualizar o sistema. +
+
+
+
+
diff --git a/src/app/pages/mve-auditoria/mve-auditoria.scss b/src/app/pages/mve-auditoria/mve-auditoria.scss new file mode 100644 index 0000000..7b8eafb --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.scss @@ -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; + } +} diff --git a/src/app/pages/mve-auditoria/mve-auditoria.ts b/src/app/pages/mve-auditoria/mve-auditoria.ts new file mode 100644 index 0000000..4a8837e --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } + } +} diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index a9517bc..fa6e752 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -69,7 +69,7 @@ - diff --git a/src/app/pages/parcelamentos/parcelamentos.scss b/src/app/pages/parcelamentos/parcelamentos.scss index 8a34e91..d606fec 100644 --- a/src/app/pages/parcelamentos/parcelamentos.scss +++ b/src/app/pages/parcelamentos/parcelamentos.scss @@ -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; diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index bd879df..24b7125 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -128,7 +128,7 @@ {{ macrophonyCompact ? 'Expandir' : 'Compactar' }} - @@ -447,7 +447,7 @@ {{ group.compact ? 'Expandir' : 'Compactar' }} - diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss index 80a7539..2f3f3f6 100644 --- a/src/app/pages/resumo/resumo.scss +++ b/src/app/pages/resumo/resumo.scss @@ -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; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 89a9b5a..bd292e3 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -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); diff --git a/src/app/services/mve-audit.service.ts b/src/app/services/mve-audit.service.ts new file mode 100644 index 0000000..3bedad1 --- /dev/null +++ b/src/app/services/mve-audit.service.ts @@ -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 { + const form = new FormData(); + form.append('file', file); + return this.http + .post(`${this.baseUrl}/preview`, form) + .pipe(tap((run) => this.cacheRun(run))); + } + + getById(id: string): Observable { + return this.http + .get(`${this.baseUrl}/${id}`) + .pipe(tap((run) => this.cacheRun(run))); + } + + getLatest(): Observable { + return this.http + .get(`${this.baseUrl}/latest`) + .pipe(tap((run) => this.cacheRun(run))); + } + + apply(runId: string, issueIds?: string[]): Observable { + return this.http.post(`${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 { + 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); + } +}