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, LineHistoricoQuery } 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-linhas', standalone: true, imports: [CommonModule, FormsModule, CustomSelectComponent], templateUrl: './historico-linhas.html', styleUrls: ['./historico-linhas.scss'], }) export class HistoricoLinhas 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; filterLine = ''; filterPageName = ''; filterAction = ''; filterUser = ''; dateFrom = ''; dateTo = ''; readonly pageOptions: SelectOption[] = [ { value: '', label: 'Todas as origens' }, { value: 'Geral', label: 'Geral' }, { value: 'Mureg', label: 'Mureg' }, { value: 'Troca de número', label: 'Troca de número' }, { value: 'Vigência', label: 'Vigência' }, { value: 'Parcelamentos', label: 'Parcelamentos' }, ]; 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.filterLine = ''; 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_linhas_${timestamp}`, sheetName: 'HistoricoLinhas', 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: 'DDD Anterior', value: (log) => this.summaryFor(log).beforeDdd ?? '' }, { header: 'DDD Novo', value: (log) => this.summaryFor(log).afterDdd ?? '' }, { 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 linhas.'); } 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 normalizedLineTerm(): string { return (this.filterLine || '').trim(); } get hasLineFilter(): boolean { return !!this.normalizedLineTerm; } 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 statusCountInPage(): number { return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length; } get trocaCountInPage(): number { return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length; } get muregCountInPage(): number { return this.logs.filter((log) => this.summaryFor(log).tone === 'mureg').length; } private fetch(): void { this.loading = true; this.error = false; this.errorMsg = ''; this.expandedLogId = null; const query: LineHistoricoQuery = { ...this.buildBaseQuery(), line: this.normalizedLineTerm || undefined, page: this.page, pageSize: this.pageSize, }; this.historicoService.listByLine(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 da linha. 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.listByLine({ ...this.buildBaseQuery(), line: this.normalizedLineTerm || 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'); const linhaAntiga = this.findChange(log, 'linhaantiga'); const linhaNova = this.findChange(log, 'linhanova'); const muregLike = entity === 'muregline' || page.includes('mureg'); if (muregLike) { const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue); const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue); return { title: 'Troca de Mureg', description: 'Linha alterada no fluxo de Mureg.', before, after, beforeDdd: this.extractDdd(before), afterDdd: this.extractDdd(after), tone: 'mureg', }; } 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 (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', }; } 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', }; } 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); } } }