feat: adicionando autitoria completa MVE
This commit is contained in:
parent
77973fc516
commit
c609953352
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export class AppComponent {
|
|||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/historico-linhas',
|
||||
'/historico-chips',
|
||||
'/perfil',
|
||||
'/system',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/historico-linhas',
|
||||
'/historico-chips',
|
||||
'/solicitacoes',
|
||||
'/auditoria-mve',
|
||||
'/perfil',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,21 @@
|
|||
</div>
|
||||
<div class="hero-data">
|
||||
<span class="hero-label">{{ k.title }}</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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,20 +110,26 @@
|
|||
</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="toolbar-left">
|
||||
<div class="view-tabs">
|
||||
<button type="button" class="filter-tab" [class.active]="viewMode === 'PENDING'" (click)="setViewMode('PENDING')">
|
||||
Para atualizar
|
||||
|
|
@ -136,12 +142,48 @@
|
|||
</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">
|
||||
<div class="input-group input-group-sm search-group">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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">Sem ação</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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue