574 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|