+
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;
}
}