line-gestao-frontend/src/app/pages/mve-auditoria/mve-auditoria.ts

478 lines
14 KiB
TypeScript

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 MveIssueViewMode = 'PENDING' | 'APPLIED' | 'ALL';
type MveIssueCategory = 'ALL' | 'STATUS' | 'LINE' | 'CHIP';
@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<HTMLElement>;
loadingLatest = false;
processing = false;
syncing = false;
selectedFile: File | null = null;
auditResult: MveAuditRun | null = null;
applyResult: ApplyMveAuditResult | null = null;
errorMessage = '';
toastMessage = '';
searchTerm = '';
viewMode: MveIssueViewMode = 'PENDING';
issueCategory: MveIssueCategory = 'ALL';
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 syncableIssues(): MveAuditIssue[] {
return this.relevantIssues.filter((issue) => issue.syncable && !issue.applied);
}
get relevantIssues(): MveAuditIssue[] {
const issues = this.auditResult?.issues ?? [];
return issues
.filter((issue) => this.issueHasRelevantDifference(issue))
.sort((left, right) => Number(left.applied) - Number(right.applied));
}
get filteredIssues(): MveAuditIssue[] {
const query = this.normalizeSearch(this.searchTerm);
return this.relevantIssues.filter((issue) => {
if (this.viewMode === 'PENDING' && issue.applied) return false;
if (this.viewMode === 'APPLIED' && !issue.applied) return false;
if (!this.matchesIssueCategory(issue)) return false;
if (!query) return true;
const haystack = [
issue.numeroLinha,
issue.issueType,
issue.actionSuggestion,
issue.systemStatus,
issue.reportStatus,
issue.systemSnapshot?.numeroLinha,
issue.reportSnapshot?.numeroLinha,
issue.systemSnapshot?.chip,
issue.reportSnapshot?.chip,
issue.situation,
issue.notes,
...(issue.differences ?? []).flatMap((difference) => [
difference.label,
difference.systemValue,
difference.reportValue,
]),
]
.map((value) => this.normalizeSearch(value))
.join(' ');
return haystack.includes(query);
});
}
get pagedIssues(): MveAuditIssue[] {
const offset = (this.page - 1) * this.pageSize;
return this.filteredIssues.slice(offset, offset + this.pageSize);
}
get totalPages(): number {
return computeTotalPages(this.filteredIssues.length, this.pageSize);
}
get pageNumbers(): number[] {
return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
return computePageStart(this.filteredIssues.length, this.page, this.pageSize);
}
get pageEnd(): number {
return computePageEnd(this.filteredIssues.length, this.page, this.pageSize);
}
get totalDifferencesCount(): number {
if (!this.auditResult) return 0;
return this.auditResult.summary.totalStatusDivergences + this.auditResult.summary.totalDataDivergences;
}
get manualReviewIssuesCount(): number {
return this.relevantIssues.filter((issue) => !issue.syncable && !issue.applied).length;
}
get statusIssuesCount(): number {
return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'status')).length;
}
get lineIssuesCount(): number {
return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'line')).length;
}
get chipIssuesCount(): number {
return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'chip')).length;
}
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<void> {
this.loadingLatest = true;
this.errorMessage = '';
try {
this.auditResult = await firstValueFrom(this.mveAuditService.getLatest());
this.applyResult = null;
this.issueCategory = 'ALL';
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<void> {
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.issueCategory = 'ALL';
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 syncIssues(): Promise<void> {
if (!this.auditResult || this.syncableIssues.length === 0 || this.syncing) {
return;
}
const confirmed = await confirmActionModal({
title: 'Atualizar sistema',
message: `${this.syncableIssues.length} ocorrência(s) sincronizável(is) serão aplicadas com base no relatório 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('Atualizações aplicadas com sucesso.');
} catch (error) {
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar o sistema.');
} finally {
this.syncing = false;
}
}
onSearchChange(): void {
this.page = 1;
}
onPageSizeChange(): void {
this.page = 1;
}
setViewMode(mode: MveIssueViewMode): void {
this.viewMode = mode;
this.page = 1;
}
setIssueCategory(category: MveIssueCategory): void {
this.issueCategory = category;
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 || '-';
}
hasDifference(issue: MveAuditIssue, fieldKey: string): boolean {
return (issue.differences ?? []).some((difference) => difference.fieldKey === fieldKey);
}
formatValue(value?: string | null): string {
const normalized = (value ?? '').trim();
return normalized || '-';
}
issueKindLabel(issue: MveAuditIssue): string {
const hasLine = this.hasDifference(issue, 'line');
const hasChip = this.hasDifference(issue, 'chip');
const hasStatus = this.hasDifference(issue, 'status');
if (hasLine && hasStatus) return 'Troca de linha + status';
if (hasChip && hasStatus) return 'Troca de chip + status';
if (hasLine) return 'Troca de linha';
if (hasChip) return 'Troca de chip';
if (hasStatus) return 'Status';
if (issue.issueType === 'DDD_CHANGE_REVIEW') return 'Revisão de DDD';
return 'Revisão';
}
issueKindClass(issue: MveAuditIssue): string {
if (this.hasDifference(issue, 'line')) return 'is-line';
if (this.hasDifference(issue, 'chip')) return 'is-chip';
if (this.hasDifference(issue, 'status')) return 'is-status';
return issue.syncable ? 'is-neutral' : 'is-review';
}
situationClass(issue: MveAuditIssue): string {
if (issue.applied) return 'is-applied';
if (!issue.syncable) return 'is-review';
return this.issueKindClass(issue);
}
severityClass(severity: string | null | undefined): string {
const normalized = (severity ?? '').trim().toUpperCase();
if (normalized === 'HIGH') return 'is-high';
if (normalized === 'MEDIUM') return 'is-medium';
if (normalized === 'WARNING') return 'is-warning';
return 'is-neutral';
}
severityLabel(severity: string | null | undefined): string {
const normalized = (severity ?? '').trim().toUpperCase();
if (normalized === 'HIGH') return 'Alta';
if (normalized === 'MEDIUM') return 'Media';
if (normalized === 'WARNING') return 'Aviso';
return 'Info';
}
describeIssue(issue: MveAuditIssue): string {
const differences = issue.differences ?? [];
if (!differences.length) {
return issue.notes?.trim() || 'Sem diferenças detalhadas.';
}
return differences
.map((difference) => `${difference.label}: ${this.formatValue(difference.systemValue)} -> ${this.formatValue(difference.reportValue)}`)
.join(' | ');
}
private issueHasRelevantDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).length > 0;
}
private async restoreCachedAudit(): Promise<void> {
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.issueCategory = 'ALL';
this.page = 1;
} finally {
this.loadingLatest = false;
}
}
private matchesIssueCategory(issue: MveAuditIssue): boolean {
switch (this.issueCategory) {
case 'STATUS':
return this.hasDifference(issue, 'status');
case 'LINE':
return this.hasDifference(issue, 'line');
case 'CHIP':
return this.hasDifference(issue, 'chip');
default:
return true;
}
}
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<void> {
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
}
}
}