line-gestao-frontend/src/app/pages/historico-linhas/historico-linhas.ts

574 lines
17 KiB
TypeScript

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<string, EventSummary>();
private readonly idFieldExceptions = new Set<string>(['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<void> {
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<AuditLogDto>({
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<AuditLogDto[]> {
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<LineHistoricoQuery, 'line' | 'page' | 'pageSize'> {
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 | undefined>): 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<void> {
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);
}
}
}