diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f0b8b83..af24341 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -21,6 +21,7 @@ import { Resumo } from './pages/resumo/resumo'; import { Parcelamentos } from './pages/parcelamentos/parcelamentos'; import { Historico } from './pages/historico/historico'; import { HistoricoLinhas } from './pages/historico-linhas/historico-linhas'; +import { HistoricoChips } from './pages/historico-chips/historico-chips'; import { Perfil } from './pages/perfil/perfil'; import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user'; import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas'; @@ -43,6 +44,7 @@ export const routes: Routes = [ { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrFinanceiroGuard], title: 'Parcelamentos' }, { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' }, { path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' }, + { path: 'historico-chips', component: HistoricoChips, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Chips' }, { 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' }, diff --git a/src/app/app.ts b/src/app/app.ts index da02a75..2a1b14c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -43,6 +43,7 @@ export class AppComponent { '/parcelamentos', '/historico', '/historico-linhas', + '/historico-chips', '/perfil', '/system', ]; diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 00114a2..649881f 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -556,6 +556,9 @@ Histórico de Linhas + + Histórico de Chips + Solicitações diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index b8f0eda..be50d40 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -100,6 +100,7 @@ export class Header implements AfterViewInit, OnDestroy { '/parcelamentos', '/historico', '/historico-linhas', + '/historico-chips', '/solicitacoes', '/auditoria-mve', '/perfil', diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index 89eb1e0..13d6f61 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -61,7 +61,21 @@
{{ k.title }} - {{ k.value }} +
+ {{ k.value }} + + + + - + +
diff --git a/src/app/pages/dashboard/dashboard.scss b/src/app/pages/dashboard/dashboard.scss index e1076ec..f518a56 100644 --- a/src/app/pages/dashboard/dashboard.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -289,11 +289,47 @@ letter-spacing: 0.02em; } +.hero-value-row { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + .hero-value { font-size: 24px; font-weight: 800; color: var(--text-main); - margin-top: 2px; + line-height: 1; +} + +.hero-trend { + min-width: 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 800; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(148, 163, 184, 0.12); + + &.trend-up { + color: #15803d; + background: rgba(34, 197, 94, 0.16); + border-color: rgba(34, 197, 94, 0.22); + } + + &.trend-down { + color: #b91c1c; + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.22); + } + + &.trend-stable { + color: #64748b; + } } .hero-hint { diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index b9a878e..68fa500 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -32,11 +32,14 @@ import { } from '../../utils/account-operator.util'; // --- Interfaces (Mantidas intactas para não quebrar contrato) --- +type KpiTrendDirection = 'up' | 'down' | 'stable'; + type KpiCard = { key: string; title: string; value: string; icon: string; + trend: KpiTrendDirection; hint?: string; }; @@ -107,6 +110,7 @@ type DashboardKpisDto = { type DashboardDto = { kpis: DashboardKpisDto; + kpiTrends?: Record | null; topClientes: TopClienteDto[]; serieMuregUltimos12Meses: SerieMesDto[]; serieTrocaUltimos12Meses: SerieMesDto[]; @@ -490,6 +494,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private filteredLinesCache: DashboardLineListItemDto[] = []; private operatorDatasetReady = false; private lineFranquiaCacheById = new Map(); + private kpiTrendMap: Record = {}; private readonly baseApi: string; private readonly kpiNavigationMap: Record = { @@ -632,6 +637,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.dashboardApiCache = null; this.loading = false; this.dashboardRaw = null; + this.kpiTrendMap = {}; this.kpis = []; this.errorMsg = this.isNetworkError(error) ? 'Falha ao carregar o Dashboard. Verifique a conexão.' @@ -648,11 +654,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoReady = false; try { - const [operacionais, reservas] = await Promise.all([ + const [operacionais, reservas, dashboardDto] = await Promise.all([ this.fetchAllDashboardLines(false), this.fetchAllDashboardLines(true), + this.fetchDashboardReal().catch(() => null), ]); const allLines = [...operacionais, ...reservas]; + this.syncKpiTrendMap(dashboardDto?.kpiTrends ?? null); this.applyClientLineAggregates(allLines); this.loading = false; @@ -666,6 +674,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoLoading = false; this.resumoReady = false; this.dataReady = false; + this.kpiTrendMap = {}; this.errorMsg = this.isNetworkError(error) ? 'Falha ao carregar o Dashboard. Verifique a conexão.' : 'Falha ao carregar os dados do cliente.'; @@ -1796,6 +1805,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private applyDto(dto: DashboardDto) { const k = dto.kpis; this.dashboardRaw = k; + this.syncKpiTrendMap(dto.kpiTrends ?? null); this.muregLabels = (dto.serieMuregUltimos12Meses || []).map(x => x.mes); this.muregValues = (dto.serieMuregUltimos12Meses || []).map(x => x.total); @@ -2471,6 +2481,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Linhas Ativas', value: this.formatInt(overview.ativas), icon: 'bi bi-check2-circle', + trend: this.getKpiTrend('linhas_ativas'), hint: 'Status ativo', }, { @@ -2478,6 +2489,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Franquia Line Total', value: this.formatDataAllowance(overview.franquiaLineTotalGb), icon: 'bi bi-wifi', + trend: this.getKpiTrend('franquia_line_total'), hint: 'Franquia contratada', }, { @@ -2485,6 +2497,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Planos Contratados', value: this.formatInt(overview.planosContratados), icon: 'bi bi-diagram-3-fill', + trend: this.getKpiTrend('planos_contratados'), hint: 'Planos ativos na base', }, { @@ -2492,6 +2505,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Usuários com Linha', value: this.formatInt(overview.usuariosComLinha), icon: 'bi bi-people-fill', + trend: this.getKpiTrend('usuarios_com_linha'), hint: 'Usuários vinculados', }, ]; @@ -2505,7 +2519,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const add = (key: string, title: string, value: string, icon: string, hint?: string) => { if (used.has(key)) return; used.add(key); - cards.push({ key, title, value, icon, hint }); + cards.push({ key, title, value, icon, trend: this.getKpiTrend(key), hint }); }; const insights = this.insights?.kpis; @@ -2574,6 +2588,27 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.kpis = cards; } + private syncKpiTrendMap(raw: Record | null | undefined): void { + const next: Record = {}; + if (raw && typeof raw === 'object') { + Object.entries(raw).forEach(([key, value]) => { + next[key] = this.normalizeKpiTrend(value); + }); + } + this.kpiTrendMap = next; + } + + private normalizeKpiTrend(value: unknown): KpiTrendDirection { + const token = String(value ?? '').trim().toLowerCase(); + if (token === 'up') return 'up'; + if (token === 'down') return 'down'; + return 'stable'; + } + + private getKpiTrend(key: string): KpiTrendDirection { + return this.kpiTrendMap[key] ?? 'stable'; + } + // --- CHART BUILDERS (Generic) --- private tryBuildCharts() { if (!isPlatformBrowser(this.platformId)) return; diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index a46a1cb..11a4511 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -91,6 +91,9 @@ + + +
+ {{ toastMessage }} +
+ + + +
+ + + + + +
+
+
+
+
+ Chip +
+ +
+
Histórico de Chips
+ Timeline das alterações feitas em um chip específico. +
+ +
+ + +
+
+ +
+
+
+ + Filtros +
+
+ + +
+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ Eventos (filtro) + {{ total }} +
+
+ Trocas de Chip (página) + {{ chipCountInPage }} +
+
+ Trocas de Número (página) + {{ trocaCountInPage }} +
+
+ Status (página) + {{ statusCountInPage }} +
+
+
+ +
+
+
+ +
+ + + +
+ Nenhuma alteração encontrada para os filtros informados. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Data/HoraUsuárioOrigemAçãoResumo da alteraçãoDetalhes
{{ formatDateTime(log.occurredAtUtc) }} +
+ {{ displayUserName(log) }} + {{ log.userEmail || '-' }} +
+
+ {{ log.page || '-' }} + + {{ formatAction(log.action) }} + + +
{{ summary.title }}
+
{{ summary.description }}
+
+ {{ formatChangeValue(summary.before) }} + + {{ formatChangeValue(summary.after) }} +
+
+ DDD: {{ formatChangeValue(summary.beforeDdd) }} {{ formatChangeValue(summary.afterDdd) }} +
+
+
+ +
+
+
+
+ Mudanças de campos +
+ +
+
+
+ {{ change.field }} + + {{ changeTypeLabel(change.changeType) }} + +
+
+ {{ formatChangeValue(change.oldValue) }} + + {{ formatChangeValue(change.newValue) }} +
+
+
+
+ +
Sem mudanças detalhadas nesse evento.
+
+
+
+
+
+
+ + +
+
+
diff --git a/src/app/pages/historico-chips/historico-chips.ts b/src/app/pages/historico-chips/historico-chips.ts new file mode 100644 index 0000000..b7a637b --- /dev/null +++ b/src/app/pages/historico-chips/historico-chips.ts @@ -0,0 +1,554 @@ +import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } 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 { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { + HistoricoService, + AuditLogDto, + AuditChangeType, + AuditFieldChangeDto, + ChipHistoricoQuery +} from '../../services/historico.service'; +import { TableExportService } from '../../services/table-export.service'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; + +interface SelectOption { + value: string; + label: string; +} + +type EventTone = 'mureg' | 'troca' | 'status' | 'linha' | 'chip' | 'generic'; + +interface EventSummary { + title: string; + description: string; + before?: string | null; + after?: string | null; + beforeDdd?: string | null; + afterDdd?: string | null; + tone: EventTone; +} + +@Component({ + selector: 'app-historico-chips', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './historico-chips.html', + styleUrls: ['../historico-linhas/historico-linhas.scss'], +}) +export class HistoricoChips implements OnInit { + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + logs: AuditLogDto[] = []; + loading = false; + exporting = false; + error = false; + errorMsg = ''; + toastMessage = ''; + + expandedLogId: string | null = null; + + page = 1; + pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; + total = 0; + + filterChip = ''; + filterPageName = ''; + filterAction = ''; + filterUser = ''; + dateFrom = ''; + dateTo = ''; + + readonly pageOptions: SelectOption[] = [ + { value: '', label: 'Todas as origens' }, + { value: 'Geral', label: 'Geral' }, + { value: 'Troca de número', label: 'Troca de número' }, + { value: 'Chips Virgens e Recebidos', label: 'Chips Virgens e Recebidos' }, + ]; + + readonly actionOptions: SelectOption[] = [ + { value: '', label: 'Todas as ações' }, + { value: 'CREATE', label: 'Criação' }, + { value: 'UPDATE', label: 'Atualização' }, + { value: 'DELETE', label: 'Exclusão' }, + ]; + + private readonly summaryCache = new Map(); + private readonly idFieldExceptions = new Set(['iccid']); + + constructor( + private readonly historicoService: HistoricoService, + private readonly cdr: ChangeDetectorRef, + @Inject(PLATFORM_ID) private readonly platformId: object, + private readonly tableExportService: TableExportService + ) {} + + ngOnInit(): void { + this.fetch(); + } + + applyFilters(): void { + this.page = 1; + this.fetch(); + } + + refresh(): void { + this.fetch(); + } + + clearFilters(): void { + this.filterChip = ''; + this.filterPageName = ''; + this.filterAction = ''; + this.filterUser = ''; + this.dateFrom = ''; + this.dateTo = ''; + this.page = 1; + this.logs = []; + this.total = 0; + this.error = false; + this.errorMsg = ''; + this.summaryCache.clear(); + this.fetch(); + } + + onPageSizeChange(): void { + this.page = 1; + this.fetch(); + } + + goToPage(target: number): void { + this.page = clampPage(target, this.totalPages); + this.fetch(); + } + + toggleDetails(log: AuditLogDto, event?: Event): void { + if (event) event.stopPropagation(); + this.expandedLogId = this.expandedLogId === log.id ? null : log.id; + } + + async onExport(): Promise { + if (this.exporting) return; + + this.exporting = true; + try { + const allLogs = await this.fetchAllLogsForExport(); + if (!allLogs.length) { + await this.showToast('Nenhum evento encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `historico_chips_${timestamp}`, + sheetName: 'HistoricoChips', + rows: allLogs, + columns: [ + { header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' }, + { header: 'Usuario', value: (log) => this.displayUserName(log) }, + { header: 'E-mail', value: (log) => log.userEmail ?? '' }, + { header: 'Origem', value: (log) => log.page ?? '' }, + { header: 'Acao', value: (log) => this.formatAction(log.action) }, + { header: 'Evento', value: (log) => this.summaryFor(log).title }, + { header: 'Resumo', value: (log) => this.summaryFor(log).description }, + { header: 'Valor Anterior', value: (log) => this.summaryFor(log).before ?? '' }, + { header: 'Valor Novo', value: (log) => this.summaryFor(log).after ?? '' }, + { header: 'Mudancas', value: (log) => this.formatChangesSummary(log) }, + ], + }); + + await this.showToast(`Planilha exportada com ${allLogs.length} evento(s).`); + } catch { + await this.showToast('Erro ao exportar histórico de chips.'); + } finally { + this.exporting = false; + } + } + + formatDateTime(value?: string | null): string { + if (!value) return '-'; + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return '-'; + return dt.toLocaleString('pt-BR'); + } + + displayUserName(log: AuditLogDto): string { + const name = (log.userName || '').trim(); + return name ? name : 'SISTEMA'; + } + + formatAction(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (!value) return '-'; + if (value === 'CREATE') return 'Criação'; + if (value === 'UPDATE') return 'Atualização'; + if (value === 'DELETE') return 'Exclusão'; + return 'Outro'; + } + + actionClass(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (value === 'CREATE') return 'action-create'; + if (value === 'UPDATE') return 'action-update'; + if (value === 'DELETE') return 'action-delete'; + return 'action-default'; + } + + changeTypeLabel(type?: AuditChangeType | string | null): string { + if (!type) return 'Alterado'; + if (type === 'added') return 'Adicionado'; + if (type === 'removed') return 'Removido'; + return 'Alterado'; + } + + changeTypeClass(type?: AuditChangeType | string | null): string { + if (type === 'added') return 'change-added'; + if (type === 'removed') return 'change-removed'; + return 'change-modified'; + } + + formatChangeValue(value?: string | null): string { + if (value === undefined || value === null || value === '') return '-'; + return String(value); + } + + summaryFor(log: AuditLogDto): EventSummary { + const cached = this.summaryCache.get(log.id); + if (cached) return cached; + const summary = this.buildEventSummary(log); + this.summaryCache.set(log.id, summary); + return summary; + } + + toneClass(tone: EventTone): string { + return `tone-${tone}`; + } + + trackByLog(_: number, log: AuditLogDto): string { + return log.id; + } + + trackByField(_: number, change: AuditFieldChangeDto): string { + return `${change.field}-${change.oldValue ?? ''}-${change.newValue ?? ''}`; + } + + visibleChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return this.publicChanges(log); + } + + get normalizedChipTerm(): string { + return (this.filterChip || '').trim(); + } + + get hasChipFilter(): boolean { + return !!this.normalizedChipTerm; + } + + get totalPages(): number { + return computeTotalPages(this.total || 0, this.pageSize); + } + + get pageNumbers(): number[] { + return buildPageNumbers(this.page, this.totalPages); + } + + get pageStart(): number { + return computePageStart(this.total || 0, this.page, this.pageSize); + } + + get pageEnd(): number { + return computePageEnd(this.total || 0, this.page, this.pageSize); + } + + get chipCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'chip').length; + } + + get trocaCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length; + } + + get statusCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length; + } + + private fetch(): void { + this.loading = true; + this.error = false; + this.errorMsg = ''; + this.expandedLogId = null; + + const query: ChipHistoricoQuery = { + ...this.buildBaseQuery(), + chip: this.normalizedChipTerm || undefined, + page: this.page, + pageSize: this.pageSize, + }; + + this.historicoService.listByChip(query).subscribe({ + next: (res) => { + this.logs = res.items || []; + this.total = res.total || 0; + this.page = res.page || this.page; + this.pageSize = res.pageSize || this.pageSize; + this.loading = false; + this.rebuildSummaryCache(); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + this.error = true; + this.logs = []; + this.total = 0; + this.summaryCache.clear(); + if (err?.status === 403) { + this.errorMsg = 'Acesso restrito.'; + return; + } + this.errorMsg = 'Erro ao carregar histórico do chip. Tente novamente.'; + } + }); + } + + private async fetchAllLogsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: AuditLogDto[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.historicoService.listByChip({ + ...this.buildBaseQuery(), + chip: this.normalizedChipTerm || undefined, + page, + pageSize, + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all; + } + + private buildBaseQuery(): Omit { + return { + pageName: this.filterPageName || undefined, + action: this.filterAction || undefined, + user: this.filterUser?.trim() || undefined, + dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, + dateTo: this.toIsoDate(this.dateTo, true) || undefined, + }; + } + + private rebuildSummaryCache(): void { + this.summaryCache.clear(); + this.logs.forEach((log) => { + this.summaryCache.set(log.id, this.buildEventSummary(log)); + }); + } + + private buildEventSummary(log: AuditLogDto): EventSummary { + const page = (log.page || '').toLowerCase(); + const entity = (log.entityName || '').toLowerCase(); + + const linhaChange = this.findChange(log, 'linha'); + const statusChange = this.findChange(log, 'status'); + const chipChange = this.findChange(log, 'chip', 'iccid', 'numerodochip'); + const linhaAntiga = this.findChange(log, 'linhaantiga'); + const linhaNova = this.findChange(log, 'linhanova'); + + const trocaLike = entity === 'trocanumeroline' || page.includes('troca'); + if (trocaLike) { + const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue); + const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue); + return { + title: 'Troca de Número', + description: 'Linha antiga substituída por uma nova.', + before, + after, + beforeDdd: this.extractDdd(before), + afterDdd: this.extractDdd(after), + tone: 'troca', + }; + } + + if (chipChange) { + return { + title: 'Alteração de Chip', + description: 'ICCID/chip atualizado na linha.', + before: this.firstFilled(chipChange.oldValue), + after: this.firstFilled(chipChange.newValue), + tone: 'chip', + }; + } + + if (statusChange) { + const oldStatus = this.firstFilled(statusChange.oldValue); + const newStatus = this.firstFilled(statusChange.newValue); + const wasBlocked = this.isBlockedStatus(oldStatus); + const isBlocked = this.isBlockedStatus(newStatus); + let description = 'Status da linha atualizado.'; + if (!wasBlocked && isBlocked) description = 'Linha foi bloqueada.'; + if (wasBlocked && !isBlocked) description = 'Linha foi desbloqueada.'; + return { + title: 'Status da Linha', + description, + before: oldStatus, + after: newStatus, + tone: 'status', + }; + } + + if (linhaChange) { + return { + title: 'Alteração da Linha', + description: 'Número da linha foi atualizado.', + before: this.firstFilled(linhaChange.oldValue), + after: this.firstFilled(linhaChange.newValue), + beforeDdd: this.extractDdd(linhaChange.oldValue), + afterDdd: this.extractDdd(linhaChange.newValue), + tone: 'linha', + }; + } + + const first = this.publicChanges(log)[0]; + if (first) { + return { + title: 'Outras alterações', + description: `Campo ${first.field} foi atualizado.`, + before: this.firstFilled(first.oldValue), + after: this.firstFilled(first.newValue), + tone: 'generic', + }; + } + + return { + title: 'Sem detalhes', + description: 'Não há mudanças detalhadas registradas para este evento.', + tone: 'generic', + }; + } + + private findChange(log: AuditLogDto, ...fields: string[]): AuditFieldChangeDto | null { + if (!fields.length) return null; + const normalizedTargets = new Set(fields.map((field) => this.normalizeField(field))); + return (log.changes || []).find((change) => normalizedTargets.has(this.normalizeField(change.field))) || null; + } + + private normalizeField(value?: string | null): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .trim(); + } + + private firstFilled(...values: Array): string | null { + for (const value of values) { + const normalized = (value ?? '').toString().trim(); + if (normalized) return normalized; + } + return null; + } + + private formatChangesSummary(log: AuditLogDto): string { + const changes = this.publicChanges(log); + if (!changes.length) return ''; + return changes + .map((change) => { + const field = change?.field ?? 'campo'; + const oldValue = this.formatChangeValue(change?.oldValue); + const newValue = this.formatChangeValue(change?.newValue); + return `${field}: ${oldValue} -> ${newValue}`; + }) + .join(' | '); + } + + private publicChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return (log?.changes ?? []).filter((change) => !this.isHiddenIdField(change?.field)); + } + + private isHiddenIdField(field?: string | null): boolean { + const normalized = this.normalizeField(field); + if (!normalized) return false; + if (this.idFieldExceptions.has(normalized)) return false; + if (normalized === 'id') return true; + return normalized.endsWith('id'); + } + + private isBlockedStatus(status?: string | null): boolean { + const normalized = (status ?? '').toLowerCase().trim(); + if (!normalized) return false; + return ( + normalized.includes('bloque') || + normalized.includes('perda') || + normalized.includes('roubo') || + normalized.includes('suspens') + ); + } + + private extractDdd(value?: string | null): string | null { + const digits = this.digitsOnly(value); + if (!digits) return null; + + if (digits.startsWith('55') && digits.length >= 12) { + return digits.slice(2, 4); + } + if (digits.length >= 10) { + return digits.slice(0, 2); + } + if (digits.length >= 2) { + return digits.slice(0, 2); + } + return null; + } + + private digitsOnly(value?: string | null): string { + return (value ?? '').replace(/\D/g, ''); + } + + private toIsoDate(value: string, endOfDay: boolean): string | null { + if (!value) return null; + const time = endOfDay ? '23:59:59' : '00:00:00'; + const date = new Date(`${value}T${time}`); + if (isNaN(date.getTime())) return null; + return date.toISOString(); + } + + private async showToast(message: string): Promise { + if (!isPlatformBrowser(this.platformId)) return; + this.toastMessage = message; + this.cdr.detectChanges(); + if (!this.successToast?.nativeElement) return; + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { + autohide: true, + delay: 3000 + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/pages/historico-linhas/historico-linhas.html b/src/app/pages/historico-linhas/historico-linhas.html index eb67ad6..a000b4a 100644 --- a/src/app/pages/historico-linhas/historico-linhas.html +++ b/src/app/pages/historico-linhas/historico-linhas.html @@ -30,10 +30,10 @@
- - @@ -58,11 +58,11 @@
- + @@ -135,10 +135,6 @@
-
- Informe a linha no filtro para carregar o histórico detalhado. -
-
@@ -148,8 +144,8 @@
-
- Nenhuma alteração encontrada para a linha informada. +
+ Nenhuma alteração encontrada para os filtros informados.
diff --git a/src/app/pages/historico-linhas/historico-linhas.ts b/src/app/pages/historico-linhas/historico-linhas.ts index 9e1b5cd..22c5307 100644 --- a/src/app/pages/historico-linhas/historico-linhas.ts +++ b/src/app/pages/historico-linhas/historico-linhas.ts @@ -96,7 +96,7 @@ export class HistoricoLinhas implements OnInit { ) {} ngOnInit(): void { - // Tela inicia aguardando o usuário informar a linha. + this.fetch(); } applyFilters(): void { @@ -121,6 +121,7 @@ export class HistoricoLinhas implements OnInit { this.error = false; this.errorMsg = ''; this.summaryCache.clear(); + this.fetch(); } onPageSizeChange(): void { @@ -141,12 +142,6 @@ export class HistoricoLinhas implements OnInit { async onExport(): Promise { if (this.exporting) return; - const lineTerm = this.normalizedLineTerm; - if (!lineTerm) { - await this.showToast('Informe a linha para exportar.'); - return; - } - this.exporting = true; try { const allLogs = await this.fetchAllLogsForExport(); @@ -292,17 +287,6 @@ export class HistoricoLinhas implements OnInit { } private fetch(): void { - const lineTerm = this.normalizedLineTerm; - if (!lineTerm) { - this.logs = []; - this.total = 0; - this.error = true; - this.errorMsg = 'Informe a linha para consultar o histórico.'; - this.loading = false; - this.summaryCache.clear(); - return; - } - this.loading = true; this.error = false; this.errorMsg = ''; @@ -310,7 +294,7 @@ export class HistoricoLinhas implements OnInit { const query: LineHistoricoQuery = { ...this.buildBaseQuery(), - line: lineTerm, + line: this.normalizedLineTerm || undefined, page: this.page, pageSize: this.pageSize, }; @@ -330,10 +314,6 @@ export class HistoricoLinhas implements OnInit { this.logs = []; this.total = 0; this.summaryCache.clear(); - if (err?.status === 400) { - this.errorMsg = err?.error?.message || 'Informe uma linha válida.'; - return; - } if (err?.status === 403) { this.errorMsg = 'Acesso restrito.'; return; @@ -344,9 +324,6 @@ export class HistoricoLinhas implements OnInit { } private async fetchAllLogsForExport(): Promise { - const lineTerm = this.normalizedLineTerm; - if (!lineTerm) return []; - const pageSize = 500; let page = 1; let expectedTotal = 0; @@ -356,7 +333,7 @@ export class HistoricoLinhas implements OnInit { const response = await firstValueFrom( this.historicoService.listByLine({ ...this.buildBaseQuery(), - line: lineTerm, + line: this.normalizedLineTerm || undefined, page, pageSize, }) diff --git a/src/app/pages/mve-auditoria/mve-auditoria.html b/src/app/pages/mve-auditoria/mve-auditoria.html index 69711c1..26ccc58 100644 --- a/src/app/pages/mve-auditoria/mve-auditoria.html +++ b/src/app/pages/mve-auditoria/mve-auditoria.html @@ -34,7 +34,7 @@ Ultima conferencia Carregando... - @@ -45,8 +45,8 @@
Conferencia

- Use o relatorio da Vivo para conferir se o status da linha esta igual ao do sistema. - Os outros campos nao entram como erro nesta tela. + Use o relatorio da Vivo para conferir se status, linha e chip estão alinhados com o sistema. + Mudanças só de DDD continuam sendo sinalizadas apenas para revisão manual.

@@ -110,30 +110,72 @@
Com diferenca - {{ audit.summary.totalStatusDivergences }} + {{ totalDifferencesCount }}
Prontas para atualizar - {{ syncableStatusIssues.length }} + {{ syncableIssues.length }} +
+
+ Revisão manual + {{ manualReviewIssuesCount }}
-
+
Só no sistema: {{ audit.summary.totalOnlyInSystem }} Só no relatório: {{ audit.summary.totalOnlyInReport }} + Avisos/ignorados: {{ ignoredIssuesCount }}
-
- - - +
+
+ + + +
+ +
+ + + + +
@@ -141,7 +183,7 @@ @@ -153,49 +195,112 @@
-
+
-
Nenhuma diferenca de status encontrada para o filtro atual.
+
Nenhuma divergência encontrada para o filtro atual.
-
+
- - + + - - + - - - -
NúmeroStatus no sistemaStatus no relatorioSistemaRelatório Situação Ação
- {{ issue.numeroLinha || '-' }} +
+
+ {{ issue.numeroLinha || issue.reportSnapshot?.numeroLinha || issue.systemSnapshot?.numeroLinha || '-' }} +
- {{ statusLabel(issue.systemStatus) }} + +
+
+ Sistema + Cadastro atual +
+
+
+ Linha + Alterada +
+ {{ formatValue(issue.systemSnapshot?.numeroLinha) }} +
+
+
+ Chip + Alterado +
+ {{ formatValue(issue.systemSnapshot?.chip) }} +
+
+
+ Status + Divergente +
+ {{ statusLabel(issue.systemStatus) }} +
+
- {{ statusLabel(issue.reportStatus) }} + +
+
+ Relatório + Importado do MVE +
+
+
+ Linha + Nova +
+ {{ formatValue(issue.reportSnapshot?.numeroLinha) }} +
+
+
+ Chip + Novo +
+ {{ formatValue(issue.reportSnapshot?.chip) }} +
+
+
+ Status + MVE +
+ {{ statusLabel(issue.reportStatus) }} +
+
-
Status diferente.
+
+
+
+ {{ issueKindLabel(issue) }} +
+
+
+
{{ issue.notes }}
+
- Pode atualizar - Atualizada - Sem ação + +
+ Pode atualizar + Atualizada + Revisar +
-