diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index bb9943d..8a75c81 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -30,7 +30,14 @@
-
+
diff --git a/src/app/pages/dashboard/dashboard.scss b/src/app/pages/dashboard/dashboard.scss index 80bfabf..9b1c902 100644 --- a/src/app/pages/dashboard/dashboard.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -178,6 +178,7 @@ display: flex; flex-direction: column; gap: 12px; + cursor: default; transition: all 0.2s ease; box-shadow: var(--shadow-sm); @@ -189,6 +190,15 @@ } } +.hero-card.hero-card-clickable { + cursor: pointer; +} + +.hero-card.hero-card-clickable:focus-visible { + outline: 2px solid rgba(227, 61, 207, 0.7); + outline-offset: 2px; +} + .hero-icon { width: 40px; height: 40px; diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index d734fed..bb22e3b 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -10,7 +10,7 @@ import { import { CommonModule, isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; import { PLATFORM_ID } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { RouterModule, Router } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; @@ -31,6 +31,11 @@ type KpiCard = { hint?: string; }; +type KpiNavigationTarget = { + route: string; + queryParams?: Record; +}; + type SerieMesDto = { mes: string; total: number; @@ -354,11 +359,29 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private chartResumoReserva?: Chart; private readonly baseApi: string; + private readonly kpiNavigationMap: Record = { + linhas_total: { route: '/geral' }, + linhas_ativas: { route: '/geral' }, + linhas_bloqueadas: { route: '/geral', queryParams: { statusMode: 'blocked' } }, + linhas_reserva: { route: '/geral', queryParams: { skil: 'RESERVA' } }, + franquia_vivo_total: { route: '/geral' }, + franquia_line_total: { route: '/geral' }, + vig_vencidos: { route: '/vigencia' }, + vig_30: { route: '/vigencia' }, + mureg_30: { route: '/mureg' }, + troca_30: { route: '/trocanumero' }, + cadastros_total: { route: '/dadosusuarios' }, + travel_com: { route: '/geral', queryParams: { additionalMode: 'with', additionalServices: 'travel' } }, + adicional_pago: { route: '/geral', queryParams: { additionalMode: 'with' } }, + planos_contratados: { route: '/resumo', queryParams: { tab: 'planos' } }, + usuarios_com_linha: { route: '/dadosusuarios' }, + }; constructor( private http: HttpClient, private resumoService: ResumoService, private authService: AuthService, + private router: Router, @Inject(PLATFORM_ID) private platformId: object ) { const raw = (environment.apiUrl || '').replace(/\/+$/, ''); @@ -1872,6 +1895,24 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { trackByKpiKey = (_: number, item: KpiCard) => item.key; + isKpiClickable(card: KpiCard): boolean { + return !!this.kpiNavigationMap[card.key]; + } + + onKpiClick(card: KpiCard): void { + const target = this.kpiNavigationMap[card.key]; + if (!target) return; + void this.router.navigate([target.route], { + queryParams: target.queryParams + }); + } + + onKpiCardKeydown(event: KeyboardEvent, card: KpiCard): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + this.onKpiClick(card); + } + private getPalette() { return { brand: '#E33DCF', diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 8e06caa..01bb3cf 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -71,6 +71,27 @@ Reservas + + + + +
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index bc3f3df..3f3436c 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -17,7 +17,7 @@ import { HttpParams, HttpErrorResponse } from '@angular/common/http'; -import { NavigationEnd, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { AuthService } from '../../services/auth.service'; @@ -42,6 +42,7 @@ type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; type CreateEntryMode = 'SINGLE' | 'BATCH'; type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; +type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120'; interface LineRow { id: string; @@ -290,6 +291,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private planAutoFill: PlanAutoFillService, private authService: AuthService, private router: Router, + private route: ActivatedRoute, private tenantSyncService: TenantSyncService, private solicitacoesLinhasService: SolicitacoesLinhasService ) {} @@ -317,6 +319,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { searchTerm = ''; filterSkil: 'ALL' | 'PF' | 'PJ' | 'RESERVA' = 'ALL'; + filterStatus: 'ALL' | 'BLOCKED' = 'ALL'; + blockedStatusMode: BlockedStatusMode = 'ALL'; additionalMode: AdditionalMode = 'ALL'; selectedAdditionalServices: AdditionalServiceKey[] = []; readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [ @@ -647,6 +651,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0; } + get hasClientSideFiltersApplied(): boolean { + return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED'; + } + get additionalModeLabel(): string { if (this.additionalMode === 'WITH') return 'Com adicionais'; if (this.additionalMode === 'WITHOUT') return 'Sem adicionais'; @@ -735,6 +743,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (this.isClientRestricted) { this.filterSkil = 'ALL'; + this.filterStatus = 'ALL'; + this.blockedStatusMode = 'ALL'; this.additionalMode = 'ALL'; this.selectedAdditionalServices = []; this.selectedClients = []; @@ -746,6 +756,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.initAnimations(); setTimeout(() => { + this.applyRouteFilters(this.route.snapshot.queryParams); this.refreshData(); if (!this.isClientRestricted) { this.loadClients(); @@ -766,9 +777,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.navigationSub = this.router.events .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) .subscribe((event) => { - const url = (event.urlAfterRedirects || '').toLowerCase(); + const urlAfterRedirects = event.urlAfterRedirects || ''; + const url = urlAfterRedirects.toLowerCase(); if (!url.includes('/geral')) return; + const parsed = this.router.parseUrl(urlAfterRedirects); + this.applyRouteFilters(parsed.queryParams ?? {}); + this.searchResolvedClient = null; if (!this.isClientRestricted) { this.loadClients(); @@ -785,6 +800,137 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }, 100); } + private applyRouteFilters(query: Record): void { + const skil = this.parseQuerySkilFilter(query['skil']); + if (skil && (!this.isClientRestricted || skil === 'ALL')) { + this.filterSkil = skil; + } + + const status = this.parseQueryStatusFilter(query['statusMode'] ?? query['statusFilter']); + if (status) { + this.filterStatus = status; + } + if (this.filterStatus !== 'BLOCKED') { + this.blockedStatusMode = 'ALL'; + } + + const blockedMode = this.parseQueryBlockedStatusMode(query['blockedMode'] ?? query['blockedType'] ?? query['statusSubtype']); + if (blockedMode) { + this.blockedStatusMode = blockedMode; + this.filterStatus = 'BLOCKED'; + } + + if (!this.isClientRestricted) { + const additionalMode = this.parseQueryAdditionalMode(query['additionalMode']); + if (additionalMode) { + this.additionalMode = additionalMode; + } + + const additionalServices = this.parseQueryAdditionalServices(query['additionalServices']); + if (additionalServices) { + this.selectedAdditionalServices = additionalServices; + } + } + + this.expandedGroup = null; + this.groupLines = []; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + } + + private parseQuerySkilFilter(value: unknown): 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 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'; + return null; + } + + private parseQueryStatusFilter(value: unknown): 'ALL' | 'BLOCKED' | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if ( + token === 'BLOCKED' || + token === 'BLOQUEADAS' || + token === 'BLOQUEADOS' || + token === 'BLOQUEADA' || + token === 'BLOQUEADO' || + token === 'BLOQUEIO' + ) { + return 'BLOCKED'; + } + return null; + } + + private parseQueryBlockedStatusMode(value: unknown): BlockedStatusMode | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if ( + token === 'PERDAROUBO' || + token === 'PERDAEROUBO' || + token === 'PERDA' || + token === 'ROUBO' + ) { + return 'PERDA_ROUBO'; + } + if ( + token === '120' || + token === '120DIAS' || + token === 'BLOQUEIO120' || + token === 'BLOQUEIO120DIAS' + ) { + return 'BLOQUEIO_120'; + } + return null; + } + + private parseQueryAdditionalMode(value: unknown): AdditionalMode | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if (token === 'WITH' || token === 'COM') return 'WITH'; + if (token === 'WITHOUT' || token === 'SEM') return 'WITHOUT'; + return null; + } + + private parseQueryAdditionalServices(value: unknown): AdditionalServiceKey[] | null { + if (value === undefined || value === null) return null; + const asString = Array.isArray(value) ? value.join(',') : String(value ?? ''); + const chunks = asString + .split(',') + .map((part) => this.mapAdditionalServiceToken(part)) + .filter((part): part is AdditionalServiceKey => !!part); + + const unique = Array.from(new Set(chunks)); + return unique; + } + + private mapAdditionalServiceToken(value: unknown): AdditionalServiceKey | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'GVD' || token === 'GESTAOVOZDADOS' || token === 'GESTAOVOZEDADOS') return 'gvd'; + if (token === 'SKEELO') return 'skeelo'; + if (token === 'NEWS' || token === 'VIVONEWS' || token === 'VIVONEWSPLUS') return 'news'; + if (token === 'TRAVEL' || token === 'TRAVELMUNDO' || token === 'VIVOTRAVELMUNDO') return 'travel'; + if (token === 'SYNC' || token === 'VIVOSYNC') return 'sync'; + if (token === 'DISPOSITIVO' || token === 'GESTAODISPOSITIVO' || token === 'VIVOGESTAODISPOSITIVO') return 'dispositivo'; + return null; + } + + private normalizeFilterToken(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9]/g, '') + .toUpperCase() + .trim(); + } + private async loadPlanRules() { try { await this.planAutoFill.load(); @@ -895,7 +1041,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { refreshData(opts?: { keepCurrentPage?: boolean }) { const keepCurrentPage = !!opts?.keepCurrentPage; this.keepPageOnNextGroupsLoad = keepCurrentPage; - if (!keepCurrentPage && this.filterSkil === 'RESERVA') { + if (!keepCurrentPage && (this.filterSkil === 'RESERVA' || this.filterStatus === 'BLOCKED')) { this.page = 1; } this.searchResolvedClient = null; @@ -921,7 +1067,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const s = (term ?? '').trim(); if (!s) return Promise.resolve(null); - const pageSize = this.hasAdditionalFiltersApplied ? '500' : '1'; + const pageSize = this.hasClientSideFiltersApplied ? '500' : '1'; let params = new HttpParams().set('page', '1').set('pageSize', pageSize).set('search', s); params = this.applyBaseFilters(params); @@ -932,7 +1078,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return new Promise((resolve) => { this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({ next: (res) => { - const source = this.hasAdditionalFiltersApplied + const source = this.hasClientSideFiltersApplied ? this.applyAdditionalFiltersClientSide(res.items ?? []) : (res.items ?? []); const first = source[0]; @@ -984,7 +1130,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const requestVersion = ++this.groupsRequestVersion; this.loading = true; - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { return this.loadOnlyThisClientGroupFromLines(clientName, requestVersion); } @@ -1051,7 +1197,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.loadingClientsList = true; this.clientsList = []; - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { void this.loadClientsFromLines(requestVersion); return; } @@ -1147,6 +1293,47 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } + toggleBlockedFilter() { + if (this.filterStatus === 'BLOCKED') { + this.filterStatus = 'ALL'; + this.blockedStatusMode = 'ALL'; + } else { + this.filterStatus = 'BLOCKED'; + } + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + + if (!this.isClientRestricted) { + this.loadClients(); + } + + this.refreshData(); + } + + setBlockedStatusMode(mode: Exclude) { + if (this.filterStatus !== 'BLOCKED') { + this.filterStatus = 'BLOCKED'; + } + + this.blockedStatusMode = this.blockedStatusMode === mode ? 'ALL' : mode; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + + if (!this.isClientRestricted) { + this.loadClients(); + } + + this.refreshData(); + } + setAdditionalMode(mode: AdditionalMode) { if (this.isClientRestricted) return; if (this.additionalMode === mode) return; @@ -1199,6 +1386,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'); + if (this.filterStatus === 'BLOCKED') { + next = next.set('statusMode', 'blocked'); + if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo'); + else if (this.blockedStatusMode === 'BLOQUEIO_120') next = next.set('statusSubtype', '120_dias'); + } if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with'); else if (this.additionalMode === 'WITHOUT') next = next.set('additionalMode', 'without'); @@ -1236,7 +1428,41 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { (this.getAdditionalValue(line, 'dispositivo') > 0); } + private resolveBlockedStatusMode(status: unknown): Exclude | null { + const normalized = this.normalizeFilterToken(status); + if (!normalized) return null; + + const hasBlockedToken = + normalized.includes('BLOQUE') || + normalized.includes('PERDA') || + normalized.includes('ROUBO') || + normalized.includes('FURTO'); + if (!hasBlockedToken) return null; + + if (normalized.includes('120')) return 'BLOQUEIO_120'; + if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) { + return 'PERDA_ROUBO'; + } + + return 'PERDA_ROUBO'; + } + + private isBlockedStatus(status: unknown): boolean { + return this.resolveBlockedStatusMode(status) !== null; + } + + private matchesBlockedStatusMode(status: unknown): boolean { + const mode = this.resolveBlockedStatusMode(status); + if (!mode) return false; + if (this.blockedStatusMode === 'ALL') return true; + return mode === this.blockedStatusMode; + } + private matchesAdditionalFilters(line: ApiLineList): boolean { + if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) { + return false; + } + const selected = this.selectedAdditionalServices; const hasSelected = selected.length > 0; @@ -1296,7 +1522,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } private async fetchAllGroupsForKpis(): Promise { - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { const lines = await this.fetchLinesForGrouping(); let groups = this.buildGroupsFromLines(lines); @@ -1411,11 +1637,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const keepCurrentPage = this.keepPageOnNextGroupsLoad; this.keepPageOnNextGroupsLoad = false; - if (!keepCurrentPage && this.filterSkil === 'RESERVA' && !hasSelection && !hasResolved) { + if (!keepCurrentPage && (this.filterSkil === 'RESERVA' || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) { this.page = 1; } - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { void this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion); return; } @@ -1573,7 +1799,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const status = ((row?.status ?? '').toString().trim()).toLowerCase(); if (status.includes('ativo')) group.ativos += 1; - if (status.includes('bloque') || status.includes('perda') || status.includes('roubo')) { + if (this.isBlockedStatus(row?.status ?? '')) { group.bloqueados += 1; } }