feat: adicionando autitoria completa MVE

This commit is contained in:
Leon 2026-03-10 17:07:04 -03:00
parent 77973fc516
commit c609953352
17 changed files with 1633 additions and 133 deletions

View File

@ -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' },

View File

@ -43,6 +43,7 @@ export class AppComponent {
'/parcelamentos',
'/historico',
'/historico-linhas',
'/historico-chips',
'/perfil',
'/system',
];

View File

@ -556,6 +556,9 @@
<a *ngIf="canViewAll" routerLink="/historico-linhas" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-diagram-3"></i> <span>Histórico de Linhas</span>
</a>
<a *ngIf="canViewAll" routerLink="/historico-chips" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim"></i> <span>Histórico de Chips</span>
</a>
<a *ngIf="canViewAll" routerLink="/solicitacoes" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-envelope-paper"></i> <span>Solicitações</span>
</a>

View File

@ -100,6 +100,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/parcelamentos',
'/historico',
'/historico-linhas',
'/historico-chips',
'/solicitacoes',
'/auditoria-mve',
'/perfil',

View File

@ -61,7 +61,21 @@
</div>
<div class="hero-data">
<span class="hero-label">{{ k.title }}</span>
<span class="hero-value">{{ k.value }}</span>
<div class="hero-value-row">
<span class="hero-value">{{ k.value }}</span>
<span
class="hero-trend"
[ngClass]="{
'trend-up': k.trend === 'up',
'trend-down': k.trend === 'down',
'trend-stable': k.trend === 'stable'
}"
[attr.aria-label]="k.trend === 'up' ? 'Aumento nas últimas 24 horas' : k.trend === 'down' ? 'Queda nas últimas 24 horas' : 'Sem alteração nas últimas 24 horas'">
<i *ngIf="k.trend === 'up'" class="bi bi-arrow-up"></i>
<i *ngIf="k.trend === 'down'" class="bi bi-arrow-down"></i>
<span *ngIf="k.trend === 'stable'">-</span>
</span>
</div>
</div>
</div>
</div>

View File

@ -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 {

View File

@ -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<string, KpiTrendDirection> | 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<string, { franquiaVivo: number | null; franquiaLine: number | null }>();
private kpiTrendMap: Record<string, KpiTrendDirection> = {};
private readonly baseApi: string;
private readonly kpiNavigationMap: Record<string, KpiNavigationTarget> = {
@ -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<string, unknown> | null | undefined): void {
const next: Record<string, KpiTrendDirection> = {};
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;

View File

@ -91,6 +91,9 @@
<button type="button" class="filter-tab" [class.active]="filterSkil === 'RESERVA'" (click)="setFilter('RESERVA')" [disabled]="loading">
<i class="bi bi-archive me-1"></i> Reservas
</button>
<button type="button" class="filter-tab" [class.active]="filterSkil === 'ESTOQUE'" (click)="setFilter('ESTOQUE')" [disabled]="loading">
<i class="bi bi-box-seam me-1"></i> Estoque
</button>
</ng-container>
<button type="button" class="filter-tab" [class.active]="filterStatus === 'BLOCKED'" (click)="toggleBlockedFilter()" [disabled]="loading">
<i class="bi bi-slash-circle me-1"></i> Bloqueadas

View File

@ -68,6 +68,7 @@ type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT';
type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM';
type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120';
type SkilFilterMode = 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 'ESTOQUE';
interface LineRow {
id: string;
@ -119,7 +120,7 @@ interface ApiLineList {
interface SmartSearchTargetResolution {
client: string;
skilFilter: 'ALL' | 'PF' | 'PJ' | 'RESERVA';
skilFilter: SkilFilterMode;
statusFilter: 'ALL' | 'BLOCKED';
blockedStatusMode: BlockedStatusMode;
requiresFilterAdjustment: boolean;
@ -403,7 +404,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
loadingLines = false;
searchTerm = '';
filterSkil: 'ALL' | 'PF' | 'PJ' | 'RESERVA' = 'ALL';
filterSkil: SkilFilterMode = 'ALL';
filterStatus: 'ALL' | 'BLOCKED' = 'ALL';
blockedStatusMode: BlockedStatusMode = 'ALL';
additionalMode: AdditionalMode = 'ALL';
@ -722,11 +723,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get isReservaExpandedGroup(): boolean {
return this.filterSkil === 'RESERVA' && !!(this.expandedGroup ?? '').trim();
return this.isReserveContextFilter() && !!(this.expandedGroup ?? '').trim();
}
get isExpandedGroupNamedReserva(): boolean {
return (this.expandedGroup ?? '').toString().trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
const group = (this.expandedGroup ?? '').toString().trim();
return group.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0
|| group.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0;
}
get hasGroupLineSelectionTools(): boolean {
@ -797,10 +800,17 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const v = (c ?? '').toString().trim();
if (!v) continue;
if (v.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) continue;
if (v.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0) continue;
set.add(v);
}
const current = (this.reservaTransferModel?.clienteDestino ?? '').toString().trim();
if (current && current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0) set.add(current);
if (
current &&
current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0 &&
current.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) !== 0
) {
set.add(current);
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
@ -1078,8 +1088,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private applyRouteFilters(query: Record<string, unknown>): void {
const skil = this.parseQuerySkilFilter(query['skil']);
if (skil && (!this.isClientRestricted || skil === 'ALL')) {
this.filterSkil = skil;
const reservaMode = this.parseQueryReservaMode(query['reservaMode']);
const resolvedSkil = skil === 'RESERVA' && reservaMode === 'stock' ? 'ESTOQUE' : skil;
if (resolvedSkil && (!this.isClientRestricted || resolvedSkil === 'ALL')) {
this.filterSkil = resolvedSkil;
}
const status = this.parseQueryStatusFilter(query['statusMode'] ?? query['statusFilter']);
@ -1117,13 +1129,23 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.page = 1;
}
private parseQuerySkilFilter(value: unknown): 'ALL' | 'PF' | 'PJ' | 'RESERVA' | null {
private parseQuerySkilFilter(value: unknown): SkilFilterMode | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ALL' || token === 'TODOS') return 'ALL';
if (token === 'PF' || token === 'PESSOAFISICA') return 'PF';
if (token === 'PJ' || token === 'PESSOAJURIDICA') return 'PJ';
if (token === 'RESERVA' || token === 'RESERVAS') return 'RESERVA';
if (token === 'ESTOQUE' || token === 'STOCK') return 'ESTOQUE';
return null;
}
private parseQueryReservaMode(value: unknown): 'assigned' | 'stock' | 'all' | null {
const token = this.normalizeFilterToken(value);
if (!token) return null;
if (token === 'ASSIGNED' || token === 'RESERVA' || token === 'RESERVAS') return 'assigned';
if (token === 'STOCK' || token === 'ESTOQUE') return 'stock';
if (token === 'ALL' || token === 'TODOS') return 'all';
return null;
}
@ -1209,6 +1231,30 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
.trim();
}
private isReserveContextFilter(filter: SkilFilterMode = this.filterSkil): boolean {
return filter === 'RESERVA' || filter === 'ESTOQUE';
}
private isStockFilter(filter: SkilFilterMode = this.filterSkil): boolean {
return filter === 'ESTOQUE';
}
private isStockClientName(value: unknown): boolean {
return (value ?? '').toString().trim().localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0;
}
private getClientFallbackLabel(emptyFallback = '', filter: SkilFilterMode = this.filterSkil): string {
if (filter === 'ESTOQUE') return 'ESTOQUE';
if (filter === 'RESERVA') return 'RESERVA';
return emptyFallback;
}
private getReservaModeForApi(filter: SkilFilterMode = this.filterSkil): 'assigned' | 'stock' | null {
if (filter === 'ESTOQUE') return 'stock';
if (filter === 'RESERVA') return 'assigned';
return null;
}
private async loadPlanRules() {
try {
await this.planAutoFill.load();
@ -1350,7 +1396,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
refreshData(opts?: { keepCurrentPage?: boolean }) {
const keepCurrentPage = !!opts?.keepCurrentPage;
this.keepPageOnNextGroupsLoad = keepCurrentPage;
if (!keepCurrentPage && (this.filterSkil === 'RESERVA' || this.filterStatus === 'BLOCKED')) {
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED')) {
this.page = 1;
}
this.searchResolvedClient = null;
@ -1376,7 +1422,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return String(value ?? '').replace(/\D/g, '');
}
private resolveSkilFilterFromLine(skil: unknown): 'ALL' | 'PF' | 'PJ' | 'RESERVA' {
private resolveSkilFilterFromLine(skil: unknown, client: unknown): SkilFilterMode {
if (this.isStockClientName(client)) return 'ESTOQUE';
const parsed = this.parseQuerySkilFilter(skil);
return parsed ?? 'ALL';
}
@ -1421,7 +1468,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
term: string,
options?: {
ignoreCurrentFilters?: boolean;
skilFilter?: 'ALL' | 'PF' | 'PJ' | 'RESERVA';
skilFilter?: SkilFilterMode;
}
): Promise<ApiLineList | null> {
const s = (term ?? '').trim();
@ -1439,6 +1486,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
params = params.set('skil', 'PESSOA FÍSICA');
} else if (options?.skilFilter === 'PJ') {
params = params.set('skil', 'PESSOA JURÍDICA');
} else if (options?.skilFilter === 'ESTOQUE') {
params = params.set('skil', 'RESERVA').set('reservaMode', 'stock');
} else if (options?.skilFilter === 'RESERVA') {
params = params.set('skil', 'RESERVA');
}
@ -1465,9 +1514,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
): SmartSearchTargetResolution | null {
if (!line) return null;
const skilFilter = this.resolveSkilFilterFromLine(line?.skil);
const skilFilter = this.resolveSkilFilterFromLine(line?.skil, line?.cliente);
const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL';
const client = ((line?.cliente ?? '').toString().trim()) || (skilFilter === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE');
const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter);
return {
client,
@ -1692,7 +1741,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (requestVersion !== this.clientsRequestVersion) return;
const filteredLines = this.applyAdditionalFiltersClientSide(allLines);
const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : '';
const fallbackClient = this.getClientFallbackLabel('');
const clients = filteredLines
.map((x) => ((x.cliente ?? '').toString().trim()) || fallbackClient)
.filter((x) => !!x);
@ -1708,7 +1757,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
}
setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') {
setFilter(type: SkilFilterMode) {
if (this.isClientRestricted && type !== 'ALL') return;
const isSameFilter = this.filterSkil === type;
@ -1849,7 +1898,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.filterSkil === 'PF') next = next.set('skil', 'PESSOA FÍSICA');
else if (this.filterSkil === 'PJ') next = next.set('skil', 'PESSOA JURÍDICA');
else if (this.filterSkil === 'RESERVA') next = next.set('skil', 'RESERVA');
else if (this.isReserveContextFilter()) {
next = next.set('skil', 'RESERVA');
const reservaMode = this.getReservaModeForApi();
if (reservaMode) next = next.set('reservaMode', reservaMode);
}
if (this.filterStatus === 'BLOCKED') {
next = next.set('statusMode', 'blocked');
if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo');
@ -2139,7 +2192,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const keepCurrentPage = this.keepPageOnNextGroupsLoad;
this.keepPageOnNextGroupsLoad = false;
if (!keepCurrentPage && (this.filterSkil === 'RESERVA' || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) {
if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) {
this.page = 1;
}
@ -2280,7 +2333,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private buildGroupsFromLines(lines: ApiLineList[]): ClientGroupDto[] {
const grouped = new Map<string, ClientGroupDto>();
const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE';
const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE');
for (const row of lines ?? []) {
const client = ((row?.cliente ?? '').toString().trim()) || fallbackClient;
@ -2312,6 +2365,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private sortGroupsWithReservaFirst(groups: ClientGroupDto[]): ClientGroupDto[] {
const list = Array.isArray(groups) ? [...groups] : [];
return list.sort((a, b) => {
const aEstoque = this.isStockClientName(a?.cliente);
const bEstoque = this.isStockClientName(b?.cliente);
if (aEstoque !== bEstoque) return aEstoque ? -1 : 1;
const aReserva = (a?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
const bReserva = (b?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
if (aReserva !== bReserva) return aReserva ? -1 : 1;
@ -2657,7 +2713,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
lines = lines.filter((line) => selected.has((line.cliente ?? '').toString().trim().toUpperCase()));
}
const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE';
const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE');
const mapped = lines.map((line) => ({
id: (line.id ?? '').toString(),
item: String(line.item ?? ''),
@ -2783,7 +2839,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (this.filterSkil === 'PF') parts.push('pf');
else if (this.filterSkil === 'PJ') parts.push('pj');
else if (this.filterSkil === 'RESERVA') parts.push('reserva');
else if (this.filterSkil === 'RESERVA') parts.push('reservas');
else if (this.filterSkil === 'ESTOQUE') parts.push('estoque');
else parts.push('todas');
if (this.filterStatus === 'BLOCKED') {
@ -3046,6 +3103,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
case 'STATUS_DIVERGENCE':
case 'STATUS_AND_DATA_DIVERGENCE':
return 'status';
case 'CHIP_CHANGE_DETECTED':
case 'LINE_CHANGE_DETECTED':
case 'DATA_DIVERGENCE':
return 'data';
case 'ONLY_IN_SYSTEM':
@ -3056,6 +3115,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
case 'DUPLICATE_SYSTEM':
return 'duplicate';
case 'INVALID_ROW':
case 'DDD_CHANGE_REVIEW':
case 'UNKNOWN_STATUS':
return 'warning';
default:
@ -3521,10 +3581,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.createMode = 'NEW_LINE_IN_GROUP';
this.resetCreateModel();
this.createModel.cliente = clientName;
this.createModel.cliente = this.isStockClientName(clientName) ? 'RESERVA' : clientName;
if (this.filterSkil === 'PJ') this.createModel.skil = 'PESSOA JURÍDICA';
else if (this.filterSkil === 'RESERVA') this.createModel.skil = 'RESERVA';
else if (this.isReserveContextFilter()) this.createModel.skil = 'RESERVA';
this.syncContaEmpresaSelection(this.createModel);
@ -4605,7 +4665,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private resolveFilterSkilForApi(): string | null {
if (this.filterSkil === 'PF') return 'PESSOA FÍSICA';
if (this.filterSkil === 'PJ') return 'PESSOA JURÍDICA';
if (this.filterSkil === 'RESERVA') return 'RESERVA';
if (this.isReserveContextFilter()) return 'RESERVA';
return null;
}
@ -4681,8 +4741,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return;
}
if (clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) {
await this.showToast('O cliente de destino não pode ser RESERVA.');
if (
clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0 ||
clienteDestino.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0
) {
await this.showToast('O cliente de destino não pode ser RESERVA/ESTOQUE.');
return;
}

View File

@ -0,0 +1,274 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-danger border-0 shadow" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header border-bottom-0">
<strong class="me-auto text-primary">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark">
{{ toastMessage }}
</div>
</div>
</div>
<section class="historico-linhas-page historico-chips-page">
<span class="page-blob blob-1" aria-hidden="true"></span>
<span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-sim"></i> Chip
</div>
<div class="header-title">
<h5 class="title mb-0">Histórico de Chips</h5>
<small class="subtitle">Timeline das alterações feitas em um chip específico.</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
</div>
<div class="filters-card mt-4">
<div class="filters-head">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros</span>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="applyFilters()" [disabled]="loading">
<i class="bi bi-check2"></i> Aplicar
</button>
<button class="btn-ghost" type="button" (click)="clearFilters()" [disabled]="loading">
<i class="bi bi-x-circle"></i> Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field line-field">
<label>Chip</label>
<input
type="text"
inputmode="numeric"
placeholder="Opcional"
[(ngModel)]="filterChip"
[disabled]="loading"
/>
</div>
<div class="filter-field">
<label>Origem</label>
<app-select
class="select-glass"
size="sm"
[options]="pageOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterPageName"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Ação</label>
<app-select
class="select-glass"
size="sm"
[options]="actionOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterAction"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Usuário</label>
<input type="text" placeholder="Nome ou e-mail" [(ngModel)]="filterUser" [disabled]="loading" />
</div>
<div class="filter-field period-field">
<label>Período (De)</label>
<input type="date" [(ngModel)]="dateFrom" [disabled]="loading" />
</div>
<div class="filter-field period-field">
<label>Período (Até)</label>
<input type="date" [(ngModel)]="dateTo" [disabled]="loading" />
</div>
</div>
</div>
<div class="kpi-grid mt-3" *ngIf="logs.length > 0">
<div class="kpi-card">
<span class="kpi-label">Eventos (filtro)</span>
<strong class="kpi-value">{{ total }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Trocas de Chip (página)</span>
<strong class="kpi-value">{{ chipCountInPage }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Trocas de Número (página)</span>
<strong class="kpi-value">{{ trocaCountInPage }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Status (página)</span>
<strong class="kpi-value">{{ statusCountInPage }}</strong>
</div>
</div>
</div>
<div class="geral-body">
<div class="table-wrap">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="alert alert-danger m-4" role="alert" *ngIf="!loading && error">
{{ errorMsg || 'Erro ao carregar histórico do chip.' }}
<button class="btn btn-sm btn-outline-danger ms-3" type="button" (click)="refresh()">Tentar novamente</button>
</div>
<div class="empty-group" *ngIf="!loading && !error && logs.length === 0">
Nenhuma alteração encontrada para os filtros informados.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !error && logs.length > 0">
<thead>
<tr>
<th>Data/Hora</th>
<th>Usuário</th>
<th>Origem</th>
<th>Ação</th>
<th>Resumo da alteração</th>
<th class="actions-col">Detalhes</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let log of logs; trackBy: trackByLog">
<tr class="table-row-item" [class.expanded]="expandedLogId === log.id">
<td class="fw-bold text-muted">{{ formatDateTime(log.occurredAtUtc) }}</td>
<td>
<div class="user-cell">
<span class="user-name">{{ displayUserName(log) }}</span>
<small class="user-email">{{ log.userEmail || '-' }}</small>
</div>
</td>
<td>
<span class="origin-pill">{{ log.page || '-' }}</span>
</td>
<td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td>
<td class="summary-col">
<ng-container *ngIf="summaryFor(log) as summary">
<div class="summary-title" [ngClass]="toneClass(summary.tone)">{{ summary.title }}</div>
<div class="summary-description">{{ summary.description }}</div>
<div class="summary-diff" *ngIf="summary.before || summary.after">
<span class="old">{{ formatChangeValue(summary.before) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(summary.after) }}</span>
</div>
<div class="summary-ddd" *ngIf="summary.beforeDdd || summary.afterDdd">
DDD: {{ formatChangeValue(summary.beforeDdd) }} <i class="bi bi-arrow-right"></i> {{ formatChangeValue(summary.afterDdd) }}
</div>
</ng-container>
</td>
<td class="actions-col">
<button
class="expand-btn"
type="button"
(click)="toggleDetails(log, $event)"
[attr.aria-expanded]="expandedLogId === log.id"
[attr.aria-label]="expandedLogId === log.id ? 'Fechar detalhes' : 'Abrir detalhes'">
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
</button>
</td>
</tr>
<tr class="details-row" *ngIf="expandedLogId === log.id">
<td colspan="6">
<div class="details-panel">
<div class="details-section">
<div class="section-title">
<i class="bi bi-pencil-square"></i> Mudanças de campos
</div>
<ng-container *ngIf="visibleChanges(log) as changes">
<div class="changes-list" *ngIf="changes.length; else noChanges">
<div class="change-item" *ngFor="let change of changes; trackBy: trackByField">
<div class="change-head">
<span class="change-field">{{ change.field }}</span>
<span class="change-type" [ngClass]="changeTypeClass(change.changeType)">
{{ changeTypeLabel(change.changeType) }}
</span>
</div>
<div class="change-values">
<span class="old">{{ formatChangeValue(change.oldValue) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(change.newValue) }}</span>
</div>
</div>
</div>
</ng-container>
<ng-template #noChanges>
<div class="empty-state">Sem mudanças detalhadas nesse evento.</div>
</ng-template>
</div>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div class="geral-footer">
<div class="footer-meta">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} registros</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<app-select
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="pageSize"
(ngModelChange)="onPageSizeChange()"
[disabled]="loading">
</app-select>
</div>
</div>
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading">
<button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="page === totalPages || loading">
<button class="page-link" (click)="goToPage(page + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>

View File

@ -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<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.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<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_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<AuditLogDto[]> {
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<ChipHistoricoQuery, 'chip' | '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', '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 | 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);
}
}
}

View File

@ -30,10 +30,10 @@
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading || !hasLineFilter">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting || !hasLineFilter">
<button type="button" class="btn btn-glass btn-sm" (click)="onExport()" [disabled]="loading || exporting">
<span *ngIf="!exporting"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exporting"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
@ -58,11 +58,11 @@
<div class="filters-grid">
<div class="filter-field line-field">
<label>Linha (obrigatório)</label>
<label>Linha</label>
<input
type="text"
inputmode="numeric"
placeholder="Ex.: 11988887777"
placeholder="Opcional"
[(ngModel)]="filterLine"
[disabled]="loading"
/>
@ -135,10 +135,6 @@
<div class="geral-body">
<div class="table-wrap">
<div class="empty-group helper" *ngIf="!loading && !error && !hasLineFilter">
Informe a linha no filtro para carregar o histórico detalhado.
</div>
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
@ -148,8 +144,8 @@
<button class="btn btn-sm btn-outline-danger ms-3" type="button" (click)="refresh()">Tentar novamente</button>
</div>
<div class="empty-group" *ngIf="!loading && !error && hasLineFilter && logs.length === 0">
Nenhuma alteração encontrada para a linha informada.
<div class="empty-group" *ngIf="!loading && !error && logs.length === 0">
Nenhuma alteração encontrada para os filtros informados.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !error && logs.length > 0">

View File

@ -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<void> {
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<AuditLogDto[]> {
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,
})

View File

@ -34,7 +34,7 @@
<span *ngIf="!loadingLatest"><i class="bi bi-arrow-clockwise me-1"></i> Ultima conferencia</span>
<span *ngIf="loadingLatest"><span class="spinner-border spinner-border-sm me-2"></span> Carregando...</span>
</button>
<button type="button" class="btn btn-brand btn-sm" (click)="syncStatuses()" [disabled]="syncing || syncableStatusIssues.length === 0">
<button type="button" class="btn btn-brand btn-sm" (click)="syncIssues()" [disabled]="syncing || syncableIssues.length === 0">
<span *ngIf="!syncing"><i class="bi bi-arrow-repeat me-1"></i> Atualizar sistema</span>
<span *ngIf="syncing"><span class="spinner-border spinner-border-sm me-2"></span> Sincronizando...</span>
</button>
@ -45,8 +45,8 @@
<div>
<div class="intro-title">Conferencia</div>
<p class="intro-text mb-0">
Use o relatorio da Vivo para conferir se o <strong>status da linha</strong> esta igual ao do sistema.
Os outros campos nao entram como erro nesta tela.
Use o relatorio da Vivo para conferir se <strong>status, linha e chip</strong> estão alinhados com o sistema.
Mudanças só de DDD continuam sendo sinalizadas apenas para revisão manual.
</p>
</div>
@ -110,30 +110,72 @@
</article>
<article class="summary-card is-danger">
<span class="summary-label">Com diferenca</span>
<strong>{{ audit.summary.totalStatusDivergences }}</strong>
<strong>{{ totalDifferencesCount }}</strong>
</article>
<article class="summary-card is-brand">
<span class="summary-label">Prontas para atualizar</span>
<strong>{{ syncableStatusIssues.length }}</strong>
<strong>{{ syncableIssues.length }}</strong>
</article>
<article class="summary-card">
<span class="summary-label">Revisão manual</span>
<strong>{{ manualReviewIssuesCount }}</strong>
</article>
</div>
<div class="secondary-notes" *ngIf="audit.summary.totalOnlyInSystem > 0 || audit.summary.totalOnlyInReport > 0">
<div class="secondary-notes" *ngIf="audit.summary.totalOnlyInSystem > 0 || audit.summary.totalOnlyInReport > 0 || ignoredIssuesCount > 0">
<span class="meta-pill" *ngIf="audit.summary.totalOnlyInSystem > 0">Só no sistema: {{ audit.summary.totalOnlyInSystem }}</span>
<span class="meta-pill" *ngIf="audit.summary.totalOnlyInReport > 0">Só no relatório: {{ audit.summary.totalOnlyInReport }}</span>
<span class="meta-pill" *ngIf="ignoredIssuesCount > 0">Avisos/ignorados: {{ ignoredIssuesCount }}</span>
</div>
<div class="toolbar">
<div class="view-tabs">
<button type="button" class="filter-tab" [class.active]="viewMode === 'PENDING'" (click)="setViewMode('PENDING')">
Para atualizar
</button>
<button type="button" class="filter-tab" [class.active]="viewMode === 'APPLIED'" (click)="setViewMode('APPLIED')">
Atualizadas
</button>
<button type="button" class="filter-tab" [class.active]="viewMode === 'ALL'" (click)="setViewMode('ALL')">
Todas
</button>
<div class="toolbar-left">
<div class="view-tabs">
<button type="button" class="filter-tab" [class.active]="viewMode === 'PENDING'" (click)="setViewMode('PENDING')">
Para atualizar
</button>
<button type="button" class="filter-tab" [class.active]="viewMode === 'APPLIED'" (click)="setViewMode('APPLIED')">
Atualizadas
</button>
<button type="button" class="filter-tab" [class.active]="viewMode === 'ALL'" (click)="setViewMode('ALL')">
Todas
</button>
</div>
<div class="type-filters" *ngIf="relevantIssues.length > 0">
<button
type="button"
class="type-filter is-all"
[class.active]="issueCategory === 'ALL'"
(click)="setIssueCategory('ALL')">
<span>Todas</span>
<strong>{{ relevantIssues.length }}</strong>
</button>
<button
type="button"
class="type-filter is-line"
[class.active]="issueCategory === 'LINE'"
(click)="setIssueCategory('LINE')">
<span>Troca de linha</span>
<strong>{{ lineIssuesCount }}</strong>
</button>
<button
type="button"
class="type-filter is-chip"
[class.active]="issueCategory === 'CHIP'"
(click)="setIssueCategory('CHIP')">
<span>Troca de chip</span>
<strong>{{ chipIssuesCount }}</strong>
</button>
<button
type="button"
class="type-filter is-status"
[class.active]="issueCategory === 'STATUS'"
(click)="setIssueCategory('STATUS')">
<span>Status</span>
<strong>{{ statusIssuesCount }}</strong>
</button>
</div>
</div>
<div class="toolbar-right">
@ -141,7 +183,7 @@
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input
class="form-control"
placeholder="Buscar por linha ou status"
placeholder="Buscar por linha, chip, status ou situação"
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchChange()"
/>
@ -153,49 +195,112 @@
</div>
</div>
<div class="status-empty" *ngIf="filteredStatusIssues.length === 0">
<div class="status-empty" *ngIf="filteredIssues.length === 0">
<i class="bi bi-check2-circle"></i>
<div>Nenhuma diferenca de status encontrada para o filtro atual.</div>
<div>Nenhuma divergência encontrada para o filtro atual.</div>
</div>
<div class="table-wrap" *ngIf="filteredStatusIssues.length > 0">
<div class="table-wrap" *ngIf="filteredIssues.length > 0">
<table class="table table-modern align-middle mb-0">
<thead>
<tr>
<th>Número</th>
<th>Status no sistema</th>
<th>Status no relatorio</th>
<th>Sistema</th>
<th>Relatório</th>
<th>Situação</th>
<th>Ação</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let issue of pagedStatusIssues; trackBy: trackByIssue">
<td>
<span class="line-number-chip">{{ issue.numeroLinha || '-' }}</span>
<tr *ngFor="let issue of pagedIssues; trackBy: trackByIssue" [class.row-applied]="issue.applied">
<td class="cell-line">
<div class="line-cell-stack">
<span class="line-number-chip">{{ issue.numeroLinha || issue.reportSnapshot?.numeroLinha || issue.systemSnapshot?.numeroLinha || '-' }}</span>
</div>
</td>
<td>
<span class="status-pill" [ngClass]="statusClass(issue.systemStatus)">{{ statusLabel(issue.systemStatus) }}</span>
<td class="cell-compare">
<div class="issue-card issue-card-system">
<div class="issue-card-head">
<span class="issue-card-eyebrow">Sistema</span>
<span class="issue-card-caption">Cadastro atual</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'line')">
<div class="issue-row-head">
<span class="issue-label">Linha</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'line')">Alterada</span>
</div>
<span class="issue-value">{{ formatValue(issue.systemSnapshot?.numeroLinha) }}</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'chip')">
<div class="issue-row-head">
<span class="issue-label">Chip</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'chip')">Alterado</span>
</div>
<span class="issue-value">{{ formatValue(issue.systemSnapshot?.chip) }}</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'status')">
<div class="issue-row-head">
<span class="issue-label">Status</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'status')">Divergente</span>
</div>
<span class="status-pill" [ngClass]="statusClass(issue.systemStatus)">{{ statusLabel(issue.systemStatus) }}</span>
</div>
</div>
</td>
<td>
<span class="status-pill" [ngClass]="statusClass(issue.reportStatus)">{{ statusLabel(issue.reportStatus) }}</span>
<td class="cell-compare">
<div class="issue-card issue-card-report">
<div class="issue-card-head">
<span class="issue-card-eyebrow">Relatório</span>
<span class="issue-card-caption">Importado do MVE</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'line')">
<div class="issue-row-head">
<span class="issue-label">Linha</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'line')">Nova</span>
</div>
<span class="issue-value">{{ formatValue(issue.reportSnapshot?.numeroLinha) }}</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'chip')">
<div class="issue-row-head">
<span class="issue-label">Chip</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'chip')">Novo</span>
</div>
<span class="issue-value">{{ formatValue(issue.reportSnapshot?.chip) }}</span>
</div>
<div class="issue-row" [class.is-different]="hasDifference(issue, 'status')">
<div class="issue-row-head">
<span class="issue-label">Status</span>
<span class="field-diff-flag" *ngIf="hasDifference(issue, 'status')">MVE</span>
</div>
<span class="status-pill" [ngClass]="statusClass(issue.reportStatus)">{{ statusLabel(issue.reportStatus) }}</span>
</div>
</div>
</td>
<td>
<div class="status-diff-copy">Status diferente.</div>
<td class="cell-situation">
<div class="situation-card" [ngClass]="situationClass(issue)">
<div class="situation-top">
<span class="issue-kind-badge" [ngClass]="issueKindClass(issue)">{{ issueKindLabel(issue) }}</span>
</div>
<div class="difference-tags" *ngIf="issue.differences.length > 0">
</div>
<div class="issue-notes" *ngIf="issue.notes">{{ issue.notes }}</div>
</div>
</td>
<td>
<span class="sync-badge ready" *ngIf="issue.syncable && !issue.applied">Pode atualizar</span>
<span class="sync-badge applied" *ngIf="issue.applied">Atualizada</span>
<span class="sync-badge muted" *ngIf="!issue.syncable && !issue.applied">Sem ação</span>
<td class="cell-action">
<div class="action-card">
<span class="sync-badge ready" *ngIf="issue.syncable && !issue.applied">Pode atualizar</span>
<span class="sync-badge applied" *ngIf="issue.applied">Atualizada</span>
<span class="sync-badge muted" *ngIf="!issue.syncable && !issue.applied">Revisar</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="page-footer" *ngIf="filteredStatusIssues.length > 0">
<div class="page-footer" *ngIf="filteredIssues.length > 0">
<div class="small text-muted fw-bold">
Mostrando {{ pageStart }}{{ pageEnd }} de {{ filteredStatusIssues.length }} divergência(s) de status
Mostrando {{ pageStart }}{{ pageEnd }} de {{ filteredIssues.length }} divergência(s)
</div>
<nav>
@ -218,7 +323,7 @@
<div class="empty-state">
<i class="bi bi-file-earmark-spreadsheet"></i>
<div>Nenhuma conferencia carregada ainda.</div>
<small>Envie o relatorio da Vivo para ver as diferencas de status e atualizar o sistema.</small>
<small>Envie o relatorio da Vivo para ver divergências de status, linha e chip antes de atualizar o sistema.</small>
</div>
</ng-template>
</div>

View File

@ -290,7 +290,7 @@
.summary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
}
@ -335,16 +335,81 @@
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
align-items: flex-start;
flex-wrap: wrap;
}
.toolbar-left {
display: grid;
gap: 12px;
}
.view-tabs {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.type-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.type-filter {
border: 1px solid rgba(24, 17, 33, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 244, 248, 0.92));
color: var(--ink);
border-radius: 18px;
padding: 10px 14px;
min-width: 152px;
display: grid;
gap: 2px;
justify-items: start;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
span {
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
strong {
font-size: 16px;
letter-spacing: -0.03em;
}
&.active {
transform: translateY(-1px);
}
&.is-all.active {
background: rgba(151, 38, 136, 0.12);
border-color: rgba(151, 38, 136, 0.24);
color: var(--brand-deep);
}
&.is-line.active {
background: rgba(3, 15, 170, 0.1);
border-color: rgba(3, 15, 170, 0.2);
color: #030faa;
}
&.is-chip.active {
background: rgba(255, 178, 0, 0.14);
border-color: rgba(255, 178, 0, 0.26);
color: #8c6200;
}
&.is-status.active {
background: rgba(220, 53, 69, 0.12);
border-color: rgba(220, 53, 69, 0.22);
color: #9f1d2d;
}
}
.filter-tab {
border: 1px solid rgba(24, 17, 33, 0.08);
background: rgba(24, 17, 33, 0.04);
@ -388,6 +453,23 @@
width: 100%;
table-layout: fixed;
thead th:nth-child(1) {
width: 16%;
}
thead th:nth-child(2),
thead th:nth-child(3) {
width: 24%;
}
thead th:nth-child(4) {
width: 24%;
}
thead th:nth-child(5) {
width: 12%;
}
thead th {
background: #faf7fc;
border-bottom: 1px solid var(--line);
@ -400,6 +482,18 @@
text-align: center;
}
tbody tr {
transition: background-color 160ms ease;
&:hover {
background: rgba(151, 38, 136, 0.03);
}
&.row-applied {
background: rgba(25, 135, 84, 0.03);
}
}
tbody td {
padding: 16px;
border-top: 1px solid rgba(24, 17, 33, 0.06);
@ -408,6 +502,22 @@
}
}
.cell-line,
.cell-situation,
.cell-action {
text-align: center;
}
.cell-compare {
text-align: left;
}
.line-cell-stack {
display: grid;
gap: 10px;
justify-items: center;
}
.line-number-chip {
display: inline-flex;
align-items: center;
@ -426,6 +536,45 @@
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.issue-kind-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
line-height: 1;
border: 1px solid transparent;
}
.issue-kind-badge {
&.is-line {
background: rgba(3, 15, 170, 0.1);
border-color: rgba(3, 15, 170, 0.2);
color: #030faa;
}
&.is-chip {
background: rgba(255, 178, 0, 0.16);
border-color: rgba(255, 178, 0, 0.26);
color: #8c6200;
}
&.is-status {
background: rgba(220, 53, 69, 0.12);
border-color: rgba(220, 53, 69, 0.2);
color: #9f1d2d;
}
&.is-review,
&.is-neutral {
background: rgba(24, 17, 33, 0.06);
border-color: rgba(24, 17, 33, 0.08);
color: rgba(24, 17, 33, 0.7);
}
}
.status-pill {
display: inline-flex;
align-items: center;
@ -455,12 +604,137 @@
}
}
.status-diff-copy {
.issue-card {
display: grid;
gap: 10px;
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(24, 17, 33, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 246, 250, 0.92));
box-shadow: 0 14px 28px rgba(24, 17, 33, 0.06);
text-align: left;
}
.issue-card-system {
border-color: rgba(3, 15, 170, 0.12);
}
.issue-card-report {
border-color: rgba(151, 38, 136, 0.14);
}
.issue-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(24, 17, 33, 0.08);
}
.issue-card-eyebrow {
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--brand-deep);
}
.issue-card-caption {
font-size: 12px;
font-weight: 700;
color: rgba(24, 17, 33, 0.82);
color: var(--muted);
}
.issue-row {
display: grid;
gap: 8px;
padding: 11px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(24, 17, 33, 0.06);
&.is-different {
background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), rgba(255, 255, 255, 0.96));
border-color: rgba(227, 61, 207, 0.22);
box-shadow: inset 0 0 0 1px rgba(227, 61, 207, 0.08);
}
}
.issue-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.issue-label {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.field-diff-flag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: rgba(227, 61, 207, 0.12);
color: var(--brand-deep);
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.issue-value,
.issue-notes {
font-size: 13px;
line-height: 1.45;
color: rgba(24, 17, 33, 0.8);
}
.issue-value {
font-weight: 800;
word-break: break-word;
}
.issue-notes {
color: var(--muted);
text-align: center;
}
.situation-card {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(248, 246, 250, 0.93));
box-shadow: 0 14px 28px rgba(24, 17, 33, 0.05);
justify-items: center;
text-align: center;
&.is-applied {
background: linear-gradient(180deg, rgba(25, 135, 84, 0.06), rgba(255, 255, 255, 0.98));
}
}
.situation-top {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.action-card {
display: grid;
gap: 10px;
justify-items: center;
}
.sync-badge {
display: inline-flex;
align-items: center;
@ -473,6 +747,7 @@
&.ready {
background: rgba(255, 178, 0, 0.16);
color: #8c6200;
font-size: 11px;
}
&.applied {
@ -583,7 +858,8 @@
.intro-meta,
.secondary-notes,
.view-tabs,
.toolbar {
.toolbar,
.type-filters {
justify-content: center;
}
@ -591,12 +867,25 @@
width: 100%;
}
.toolbar-left {
width: 100%;
}
.type-filters {
width: 100%;
}
.filter-tab {
flex: 1 1 120px;
justify-content: center;
text-align: center;
}
.type-filter {
flex: 1 1 152px;
min-width: 0;
}
.search-group,
.page-size-select {
width: 100%;
@ -610,7 +899,7 @@
}
.table-modern {
min-width: 720px;
min-width: 1180px;
}
.line-number-chip {

View File

@ -19,7 +19,8 @@ import {
computeTotalPages,
} from '../../utils/pagination.util';
type MveStatusViewMode = 'PENDING' | 'APPLIED' | 'ALL';
type MveIssueViewMode = 'PENDING' | 'APPLIED' | 'ALL';
type MveIssueCategory = 'ALL' | 'STATUS' | 'LINE' | 'CHIP';
@Component({
selector: 'app-mve-auditoria',
@ -41,7 +42,8 @@ export class MveAuditoriaPage implements OnInit {
toastMessage = '';
searchTerm = '';
viewMode: MveStatusViewMode = 'PENDING';
viewMode: MveIssueViewMode = 'PENDING';
issueCategory: MveIssueCategory = 'ALL';
page = 1;
pageSize = 20;
readonly pageSizeOptions = [10, 20, 50, 100];
@ -67,30 +69,42 @@ export class MveAuditoriaPage implements OnInit {
return !!this.auditResult;
}
get syncableStatusIssues(): MveAuditIssue[] {
return this.statusIssues.filter((issue) => issue.syncable && !issue.applied);
get syncableIssues(): MveAuditIssue[] {
return this.relevantIssues.filter((issue) => issue.syncable && !issue.applied);
}
get statusIssues(): MveAuditIssue[] {
get relevantIssues(): MveAuditIssue[] {
const issues = this.auditResult?.issues ?? [];
return issues
.filter((issue) => this.issueHasStatusDifference(issue))
.filter((issue) => this.issueHasRelevantDifference(issue))
.sort((left, right) => Number(left.applied) - Number(right.applied));
}
get filteredStatusIssues(): MveAuditIssue[] {
get filteredIssues(): MveAuditIssue[] {
const query = this.normalizeSearch(this.searchTerm);
return this.statusIssues.filter((issue) => {
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(' ');
@ -99,13 +113,13 @@ export class MveAuditoriaPage implements OnInit {
});
}
get pagedStatusIssues(): MveAuditIssue[] {
get pagedIssues(): MveAuditIssue[] {
const offset = (this.page - 1) * this.pageSize;
return this.filteredStatusIssues.slice(offset, offset + this.pageSize);
return this.filteredIssues.slice(offset, offset + this.pageSize);
}
get totalPages(): number {
return computeTotalPages(this.filteredStatusIssues.length, this.pageSize);
return computeTotalPages(this.filteredIssues.length, this.pageSize);
}
get pageNumbers(): number[] {
@ -113,11 +127,32 @@ export class MveAuditoriaPage implements OnInit {
}
get pageStart(): number {
return computePageStart(this.filteredStatusIssues.length, this.page, this.pageSize);
return computePageStart(this.filteredIssues.length, this.page, this.pageSize);
}
get pageEnd(): number {
return computePageEnd(this.filteredStatusIssues.length, this.page, this.pageSize);
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 {
@ -140,6 +175,7 @@ export class MveAuditoriaPage implements OnInit {
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;
@ -201,6 +237,7 @@ export class MveAuditoriaPage implements OnInit {
try {
this.auditResult = await firstValueFrom(this.mveAuditService.preview(this.selectedFile));
this.issueCategory = 'ALL';
this.page = 1;
this.viewMode = 'PENDING';
this.searchTerm = '';
@ -212,14 +249,14 @@ export class MveAuditoriaPage implements OnInit {
}
}
async syncStatuses(): Promise<void> {
if (!this.auditResult || this.syncableStatusIssues.length === 0 || this.syncing) {
async syncIssues(): Promise<void> {
if (!this.auditResult || this.syncableIssues.length === 0 || this.syncing) {
return;
}
const confirmed = await confirmActionModal({
title: 'Atualizar status no sistema',
message: `${this.syncableStatusIssues.length} linha(s) terao o status atualizado de acordo com o relatorio da Vivo.`,
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',
@ -237,9 +274,9 @@ export class MveAuditoriaPage implements OnInit {
this.auditResult = await firstValueFrom(this.mveAuditService.getById(this.auditResult.id));
this.viewMode = 'ALL';
this.page = 1;
await this.showToast('Status atualizados com sucesso.');
await this.showToast('Atualizações aplicadas com sucesso.');
} catch (error) {
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar os status.');
this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar o sistema.');
} finally {
this.syncing = false;
}
@ -253,11 +290,16 @@ export class MveAuditoriaPage implements OnInit {
this.page = 1;
}
setViewMode(mode: MveStatusViewMode): void {
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);
}
@ -288,8 +330,71 @@ export class MveAuditoriaPage implements OnInit {
return value || '-';
}
private issueHasStatusDifference(issue: MveAuditIssue): boolean {
return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status');
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> {
@ -308,12 +413,26 @@ export class MveAuditoriaPage implements OnInit {
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()

View File

@ -52,7 +52,19 @@ export interface HistoricoQuery {
}
export interface LineHistoricoQuery {
line: string;
line?: string;
pageName?: string;
action?: AuditAction | string;
user?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
export interface ChipHistoricoQuery {
chip?: string;
pageName?: string;
action?: AuditAction | string;
user?: string;
@ -102,4 +114,20 @@ export class HistoricoService {
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico/linhas`, { params: httpParams });
}
listByChip(params: ChipHistoricoQuery): Observable<PagedResult<AuditLogDto>> {
let httpParams = new HttpParams();
if (params.chip) httpParams = httpParams.set('chip', params.chip);
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
if (params.action) httpParams = httpParams.set('action', params.action);
if (params.user) httpParams = httpParams.set('user', params.user);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
httpParams = httpParams.set('page', String(params.page || 1));
httpParams = httpParams.set('pageSize', String(params.pageSize || 10));
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico/chips`, { params: httpParams });
}
}