From 59c2cb828e5db4d7f3c23d0975364d9672da856b Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 2 Mar 2026 15:27:21 -0300 Subject: [PATCH] feat: Estilizando tela de cliente e imputs --- .../custom-select/custom-select.html | 25 +++- .../custom-select/custom-select.scss | 53 ++++++++ .../components/custom-select/custom-select.ts | 33 +++++ .../pages/dados-usuarios/dados-usuarios.html | 51 +++++--- .../pages/dados-usuarios/dados-usuarios.ts | 115 ++++++++++++------ src/app/pages/dashboard/dashboard.html | 50 ++------ src/app/pages/dashboard/dashboard.ts | 110 ++++++++++------- src/app/pages/geral/geral.html | 24 ++-- src/app/pages/geral/geral.ts | 110 ++++++++++++++++- .../system-provision-user.html | 21 ++-- .../system-provision-user.ts | 10 +- 11 files changed, 445 insertions(+), 157 deletions(-) diff --git a/src/app/components/custom-select/custom-select.html b/src/app/components/custom-select/custom-select.html index 9619462..6fd6bb0 100644 --- a/src/app/components/custom-select/custom-select.html +++ b/src/app/components/custom-select/custom-select.html @@ -11,10 +11,31 @@
+ + -
+
Nenhuma opção
diff --git a/src/app/components/custom-select/custom-select.scss b/src/app/components/custom-select/custom-select.scss index acfa098..4ffab52 100644 --- a/src/app/components/custom-select/custom-select.scss +++ b/src/app/components/custom-select/custom-select.scss @@ -111,6 +111,59 @@ padding: 6px; } +.app-select-search { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 6px; + padding: 6px 8px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 9px; + background: #fff; + + i { + color: #64748b; + font-size: 12px; + } +} + +.app-select-search-input { + flex: 1 1 auto; + min-width: 0; + border: none; + background: transparent; + outline: none; + font-size: 12px; + color: #0f172a; + padding: 0; + + &::placeholder { + color: #94a3b8; + } +} + +.app-select-search-clear { + width: 20px; + height: 20px; + border: none; + border-radius: 999px; + background: transparent; + color: #94a3b8; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + + &:hover { + background: rgba(148, 163, 184, 0.2); + color: #475569; + } +} + .app-select-option { width: 100%; border: none; diff --git a/src/app/components/custom-select/custom-select.ts b/src/app/components/custom-select/custom-select.ts index 6bd1df3..3ef4bcb 100644 --- a/src/app/components/custom-select/custom-select.ts +++ b/src/app/components/custom-select/custom-select.ts @@ -23,9 +23,12 @@ export class CustomSelectComponent implements ControlValueAccessor { @Input() valueKey = 'value'; @Input() size: 'sm' | 'md' = 'md'; @Input() disabled = false; + @Input() searchable = false; + @Input() searchPlaceholder = 'Pesquisar...'; isOpen = false; value: any = null; + searchTerm = ''; private onChange: (value: any) => void = () => {}; private onTouched: () => void = () => {}; @@ -63,10 +66,12 @@ export class CustomSelectComponent implements ControlValueAccessor { toggle(): void { if (this.disabled) return; this.isOpen = !this.isOpen; + if (!this.isOpen) this.searchTerm = ''; } close(): void { this.isOpen = false; + this.searchTerm = ''; } selectOption(option: any): void { @@ -84,6 +89,26 @@ export class CustomSelectComponent implements ControlValueAccessor { trackByValue = (_: number, option: any) => this.getOptionValue(option); + get filteredOptions(): any[] { + const opts = this.options || []; + const term = this.normalizeText(this.searchTerm); + if (!this.searchable || !term) return opts; + return opts.filter((opt) => this.normalizeText(this.getOptionLabel(opt)).includes(term)); + } + + onSearchInput(value: string): void { + this.searchTerm = value ?? ''; + } + + clearSearch(event?: Event): void { + event?.stopPropagation(); + this.searchTerm = ''; + } + + onSearchKeydown(event: KeyboardEvent): void { + event.stopPropagation(); + } + private getOptionValue(option: any): any { if (option && typeof option === 'object') { return option[this.valueKey]; @@ -103,6 +128,14 @@ export class CustomSelectComponent implements ControlValueAccessor { return (this.options || []).find((o) => this.getOptionValue(o) === value); } + private normalizeText(value: string): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { if (!this.isOpen) return; diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html index 2e34ac1..a3ebad1 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.html +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -231,34 +231,31 @@
- Vínculo com GERAL + Vínculo com Reserva
- - -
-
- + + Carregando linhas da Reserva... +
+
+ +
@@ -291,7 +288,10 @@
-
+
+ + +
@@ -357,7 +357,26 @@
-
+
+ + +
+
+ + +
diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index 6ed40b2..b94a1c9 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -24,6 +24,7 @@ interface LineOptionDto { item: number; linha: string | null; usuario: string | null; + franquiaLine?: number | null; label?: string; } @@ -94,13 +95,15 @@ export class DadosUsuarios implements OnInit { createSaving = false; createModel: any = null; createDateNascimento = ''; - clientsFromGeral: string[] = []; + createFranquiaLineTotal = 0; + editFranquiaLineTotal = 0; + editSelectedLineId = ''; + editLineOptions: LineOptionDto[] = []; lineOptionsCreate: LineOptionDto[] = []; readonly tipoPessoaOptions: SimpleOption[] = [ { label: 'Pessoa Física', value: 'PF' }, { label: 'Pessoa Jurídica', value: 'PJ' }, ]; - createClientsLoading = false; createLinesLoading = false; isSysAdmin = false; @@ -295,7 +298,11 @@ export class DadosUsuarios implements OnInit { razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '') }; this.editDateNascimento = this.toDateInput(fullData.dataNascimento); + this.editFranquiaLineTotal = 0; + this.editSelectedLineId = ''; + this.editLineOptions = []; this.editOpen = true; + this.loadReserveLinesForSelects(); }, error: () => this.showToast('Erro ao abrir edição', 'danger') }); @@ -307,6 +314,9 @@ export class DadosUsuarios implements OnInit { this.editModel = null; this.editDateNascimento = ''; this.editingId = null; + this.editSelectedLineId = ''; + this.editLineOptions = []; + this.editFranquiaLineTotal = 0; } onEditTipoChange() { @@ -369,13 +379,14 @@ export class DadosUsuarios implements OnInit { if (!this.isSysAdmin) return; this.resetCreateModel(); this.createOpen = true; - this.preloadGeralClients(); + this.loadReserveLinesForSelects(); } closeCreate() { this.createOpen = false; this.createSaving = false; this.createModel = null; + this.createFranquiaLineTotal = 0; } private resetCreateModel() { @@ -397,33 +408,9 @@ export class DadosUsuarios implements OnInit { telefoneFixo: '' }; this.createDateNascimento = ''; + this.createFranquiaLineTotal = 0; this.lineOptionsCreate = []; this.createLinesLoading = false; - this.createClientsLoading = false; - } - - private preloadGeralClients() { - this.createClientsLoading = true; - this.linesService.getClients().subscribe({ - next: (list) => { - this.clientsFromGeral = list ?? []; - this.createClientsLoading = false; - }, - error: () => { - this.clientsFromGeral = []; - this.createClientsLoading = false; - } - }); - } - - onCreateClientChange() { - const c = (this.createModel?.selectedClient ?? '').trim(); - this.createModel.mobileLineId = ''; - this.createModel.linha = ''; - this.createModel.cliente = c; - this.lineOptionsCreate = []; - - if (c) this.loadLinesForClient(c); } onCreateTipoChange() { @@ -438,12 +425,9 @@ export class DadosUsuarios implements OnInit { } } - private loadLinesForClient(cliente: string) { - const c = (cliente ?? '').trim(); - if (!c) return; - + private loadReserveLinesForSelects(onDone?: () => void) { this.createLinesLoading = true; - this.linesService.getLinesByClient(c).subscribe({ + this.linesService.getLinesByClient('RESERVA').subscribe({ next: (items: any[]) => { const mapped: LineOptionDto[] = (items ?? []) .filter(x => !!String(x?.id ?? '').trim()) @@ -457,12 +441,16 @@ export class DadosUsuarios implements OnInit { .filter(x => !!String(x.linha ?? '').trim()); this.lineOptionsCreate = mapped; + if (this.editModel) this.syncEditLineOptions(); this.createLinesLoading = false; + onDone?.(); }, error: () => { this.lineOptionsCreate = []; + this.editLineOptions = []; this.createLinesLoading = false; - this.showToast('Erro ao carregar linhas da GERAL.', 'danger'); + this.showToast('Erro ao carregar linhas da Reserva.', 'danger'); + onDone?.(); } }); } @@ -477,13 +465,56 @@ export class DadosUsuarios implements OnInit { }); } + onEditLineChange() { + const id = String(this.editSelectedLineId ?? '').trim(); + if (!id || id === '__CURRENT__') return; + this.linesService.getById(id).subscribe({ + next: (d: MobileLineDetail) => this.applyLineDetailToEdit(d), + error: () => this.showToast('Erro ao carregar dados da linha.', 'danger') + }); + } + + private syncEditLineOptions() { + if (!this.editModel) { + this.editLineOptions = []; + this.editSelectedLineId = ''; + return; + } + + const currentLine = String(this.editModel.linha ?? '').trim(); + const fromReserva = this.lineOptionsCreate.find((x) => String(x.linha ?? '').trim() === currentLine); + const options = [...this.lineOptionsCreate]; + + if (currentLine && !fromReserva) { + options.unshift({ + id: '__CURRENT__', + item: Number(this.editModel.item ?? 0), + linha: currentLine, + usuario: this.editModel.cliente ?? null, + label: `Atual • ${currentLine}` + }); + } + + this.editLineOptions = options; + this.editSelectedLineId = fromReserva?.id ?? (currentLine ? '__CURRENT__' : ''); + if (fromReserva?.id) { + this.onEditLineChange(); + } + } + private applyLineDetailToCreate(d: MobileLineDetail) { this.createModel.linha = d.linha ?? ''; - this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? ''; + this.createFranquiaLineTotal = this.toNullableNumber(d.franquiaLine) ?? 0; if (!String(this.createModel.item ?? '').trim() && d.item) { this.createModel.item = String(d.item); } + const lineClient = String(d.cliente ?? '').trim(); + const isReserva = lineClient.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; + if (!isReserva && lineClient) { + this.createModel.cliente = lineClient; + } + if ((this.createModel.tipoPessoa ?? '').toUpperCase() === 'PJ') { if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente; } else { @@ -491,6 +522,15 @@ export class DadosUsuarios implements OnInit { } } + private applyLineDetailToEdit(d: MobileLineDetail) { + if (!this.editModel) return; + this.editModel.linha = d.linha ?? this.editModel.linha; + this.editFranquiaLineTotal = this.toNullableNumber(d.franquiaLine) ?? 0; + if (!String(this.editModel.item ?? '').trim() && d.item) { + this.editModel.item = d.item; + } + } + saveCreate() { if (!this.createModel) return; this.createSaving = true; @@ -584,6 +624,11 @@ export class DadosUsuarios implements OnInit { return Number.isNaN(n) ? null : n; } + formatFranquiaLine(value: any): string { + const n = this.toNullableNumber(value) ?? 0; + return `${n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} GB`; + } + private normalizeTipo(row: UserDataRow | null | undefined): 'PF' | 'PJ' { const t = (row?.tipoPessoa ?? '').toString().trim().toUpperCase(); if (t === 'PJ') return 'PJ'; diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index 7ae6145..c76e8b0 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -354,20 +354,10 @@ Ativas {{ statusResumo.ativos | number:'1.0-0' }}
-
- - Bloqueadas - {{ statusResumo.bloqueadas | number:'1.0-0' }} -
-
- - Reserva - {{ statusResumo.reservas | number:'1.0-0' }} -
- Outros Status - {{ clientOverview.outrosStatus | number:'1.0-0' }} + Demais Linhas + {{ clientDemaisLinhas | number:'1.0-0' }}
Total @@ -393,44 +383,30 @@
-
-
-
-
-

Top Planos (Qtd. Linhas)

-

Planos com maior volume na sua operação

-
-
-
- +
+
+
+

Top Planos (Qtd. Linhas)

+

Planos com maior volume na sua operação

+
+ +
+
+

Top Usuários (Qtd. Linhas)

-

Usuários com maior concentração de linhas

+

Apenas usuários de fato (sem bloqueados/aguardando)

-
- -
-
-
-
-

Reserva por DDD

-

Linhas disponíveis em reserva por região

-
-
-
- -
-
diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index d010e61..3062b8e 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -111,6 +111,7 @@ type InsightsChartSeries = { type InsightsKpisVivo = { qtdLinhas?: number | null; totalFranquiaGb?: number | null; + totalFranquiaLine?: number | null; totalBaseMensal?: number | null; totalAdicionaisMensal?: number | null; totalGeralMensal?: number | null; @@ -429,7 +430,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.fetchAllDashboardLines(true), ]); const allLines = [...operacionais, ...reservas]; - this.applyClientLineAggregates(allLines, reservas); + this.applyClientLineAggregates(allLines); this.loading = false; this.resumoLoading = false; @@ -480,12 +481,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } private applyClientLineAggregates( - allLines: DashboardLineListItemDto[], - reservaLines: DashboardLineListItemDto[] + allLines: DashboardLineListItemDto[] ): void { const planMap = new Map(); const userMap = new Map(); - const reservaDddMap = new Map(); const franquiaBandMap = new Map(); let totalLinhas = 0; @@ -504,6 +503,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const status = this.normalizeSeriesKey(this.readLineString(line, 'status', 'Status')); const planoContrato = this.readLineString(line, 'planoContrato', 'PlanoContrato').trim(); const usuario = this.readLineString(line, 'usuario', 'Usuario').trim(); + const usuarioKey = this.normalizeSeriesKey(usuario); const franquiaLine = this.readLineNumber(line, 'franquiaLine', 'FranquiaLine'); franquiaLineTotalGb += franquiaLine > 0 ? franquiaLine : 0; @@ -527,8 +527,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const planoKey = planoContrato || 'Sem plano'; planMap.set(planoKey, (planMap.get(planoKey) ?? 0) + 1); - const userKey = usuario || 'Sem usuário'; - userMap.set(userKey, (userMap.get(userKey) ?? 0) + 1); + if (this.shouldIncludeTopUsuario(usuarioKey, status)) { + userMap.set(usuario, (userMap.get(usuario) ?? 0) + 1); + } const faixa = this.resolveFranquiaLineBand(franquiaLine); franquiaBandMap.set(faixa, (franquiaBandMap.get(faixa) ?? 0) + 1); @@ -544,11 +545,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } } - for (const line of reservaLines) { - const ddd = this.extractDddFromLine(this.readLineString(line, 'linha', 'Linha')) ?? 'Sem DDD'; - reservaDddMap.set(ddd, (reservaDddMap.get(ddd) ?? 0) + 1); - } - const topPlanos = Array.from(planMap.entries()) .map(([plano, linhas]) => ({ plano, linhas })) .sort((a, b) => b.linhas - a.linhas || a.plano.localeCompare(b.plano, 'pt-BR')) @@ -559,11 +555,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { .sort((a, b) => b.linhas - a.linhas || a.cliente.localeCompare(b.cliente, 'pt-BR')) .slice(0, this.resumoTopN); - const topReserva = Array.from(reservaDddMap.entries()) - .map(([ddd, total]) => ({ ddd, total, linhas: total })) - .sort((a, b) => b.total - a.total || a.ddd.localeCompare(b.ddd, 'pt-BR')) - .slice(0, this.resumoTopN); - const franquiaOrder = ['Sem franquia', 'Até 10 GB', '10 a 20 GB', '20 a 50 GB', 'Acima de 50 GB']; const franquiaLabels = franquiaOrder.filter((label) => (franquiaBandMap.get(label) ?? 0) > 0); this.franquiaLabels = franquiaLabels.length ? franquiaLabels : franquiaOrder; @@ -609,9 +600,9 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoClientesLabels = topUsuarios.map((x) => x.cliente); this.resumoClientesValues = topUsuarios.map((x) => x.linhas); - this.resumoTopReserva = topReserva; - this.resumoReservaLabels = topReserva.map((x) => x.ddd); - this.resumoReservaValues = topReserva.map((x) => x.total); + this.resumoTopReserva = []; + this.resumoReservaLabels = []; + this.resumoReservaValues = []; this.resumoPfPjLabels = []; this.resumoPfPjValues = []; @@ -815,6 +806,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { vivo: { qtdLinhas: this.toNumberOrNull(this.readNode(vivoRaw, 'qtdLinhas', 'QtdLinhas')), totalFranquiaGb: this.toNumberOrNull(this.readNode(vivoRaw, 'totalFranquiaGb', 'TotalFranquiaGb')), + totalFranquiaLine: this.toNumberOrNull(this.readNode(vivoRaw, 'totalFranquiaLine', 'TotalFranquiaLine')), totalBaseMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalBaseMensal', 'TotalBaseMensal')), totalAdicionaisMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalAdicionaisMensal', 'TotalAdicionaisMensal')), totalGeralMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalGeralMensal', 'TotalGeralMensal')), @@ -1188,6 +1180,28 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return cliente === 'RESERVA' || usuario === 'RESERVA' || skil === 'RESERVA'; } + private shouldIncludeTopUsuario(usuarioKey: string, statusKey: string): boolean { + if (!usuarioKey) return false; + + const invalidUserTokens = [ + 'SEMUSUARIO', + 'AGUARDANDOUSUARIO', + 'AGUARDANDO', + 'BLOQUEAR', + 'BLOQUEAD', + 'BLOQUEADO', + 'RESERVA', + 'NAOATRIBUIDO', + 'PENDENTE', + ]; + if (invalidUserTokens.some((token) => usuarioKey.includes(token))) { + return false; + } + + const blockedStatusTokens = ['BLOQUE', 'PERDA', 'ROUBO', 'SUSPEN', 'CANCEL', 'AGUARD']; + return !blockedStatusTokens.some((token) => statusKey.includes(token)); + } + private resolveFranquiaLineBand(value: number): string { if (!Number.isFinite(value) || value <= 0) return 'Sem franquia'; if (value < 10) return 'Até 10 GB'; @@ -1250,20 +1264,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { icon: 'bi bi-check2-circle', hint: 'Status ativo', }, - { - key: 'linhas_bloqueadas', - title: 'Linhas Bloqueadas', - value: this.formatInt(overview.bloqueadas), - icon: 'bi bi-slash-circle', - hint: 'Bloqueio/suspensão', - }, - { - key: 'linhas_reserva', - title: 'Linhas em Reserva', - value: this.formatInt(overview.reservas), - icon: 'bi bi-inboxes-fill', - hint: 'Disponíveis para uso', - }, { key: 'franquia_line_total', title: 'Franquia Line Total', @@ -1306,15 +1306,33 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { add('linhas_ativas', 'Linhas Ativas', this.formatInt(dashboard.ativos), 'bi bi-check2-circle', 'Status ativo'); add('linhas_bloqueadas', 'Linhas Bloqueadas', this.formatInt(dashboard.bloqueados), 'bi bi-slash-circle', 'Todos os bloqueios'); add('linhas_reserva', 'Linhas em Reserva', this.formatInt(dashboard.reservas), 'bi bi-inboxes-fill', 'Base de reserva'); - if (insights) { - add( - 'franquia_vivo_total', - 'Total Franquia Vivo', - this.formatDataAllowance(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0), - 'bi bi-diagram-3-fill', - 'Soma das franquias (Geral)' + const franquiaVivoTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaGb) + ?? this.toNumberOrNull(this.resumo?.vivoLineTotals?.franquiaTotal) + ?? (this.resumo?.vivoLineResumos ?? []).reduce( + (acc, row) => acc + (this.toNumberOrNull(row?.franquiaTotal) ?? 0), + 0 ); - } + add( + 'franquia_vivo_total', + 'Total Franquia Vivo', + this.formatDataAllowance(franquiaVivoTotal), + 'bi bi-diagram-3-fill', + 'Soma das franquias (Geral)' + ); + + const franquiaLineTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaLine) + ?? this.toNumberOrNull(this.resumo?.vivoLineTotals?.franquiaLine) + ?? (this.resumo?.vivoLineResumos ?? []).reduce( + (acc, row) => acc + (this.toNumberOrNull(row?.franquiaLine) ?? 0), + 0 + ); + add( + 'franquia_line_total', + 'Total Franquia Line', + this.formatDataAllowance(franquiaLineTotal), + 'bi bi-hdd-network-fill', + 'Soma da franquia line' + ); add('vig_vencidos', 'Vigencia Vencida', this.formatInt(dashboard.vigenciaVencidos), 'bi bi-exclamation-triangle-fill', 'Prioridade alta'); add('vig_30', 'Vence em 30 dias', this.formatInt(dashboard.vigenciaAVencer30), 'bi bi-calendar2-week-fill', 'Prioridade'); add('mureg_30', 'MUREG 30 dias', this.formatInt(dashboard.muregsUltimos30Dias), 'bi bi-arrow-repeat', 'Movimentacao'); @@ -1414,10 +1432,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { // 1. Status Pie if (this.chartStatusPie?.nativeElement) { const chartLabels = this.isCliente - ? ['Ativas', 'Bloqueadas', 'Reservas'] + ? ['Ativas', 'Demais linhas'] : ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros']; const chartData = this.isCliente - ? [this.statusResumo.ativos, this.statusResumo.bloqueadas, this.statusResumo.reservas] + ? [this.statusResumo.ativos, this.clientDemaisLinhas] : [ this.statusResumo.ativos, this.statusResumo.perdaRoubo, @@ -1426,7 +1444,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.statusResumo.outras, ]; const chartColors = this.isCliente - ? [palette.status.ativos, palette.status.blocked, palette.status.reserve] + ? [palette.status.ativos, '#cbd5e1'] : [ palette.status.ativos, palette.status.blocked, @@ -1852,6 +1870,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return Number.isNaN(n) ? null : n; } + get clientDemaisLinhas(): number { + return Math.max(0, (this.statusResumo.total ?? 0) - (this.statusResumo.ativos ?? 0)); + } + trackByKpiKey = (_: number, item: KpiCard) => item.key; private getPalette() { diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 388462a..c08a725 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -697,16 +697,20 @@
- - + +
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 6fac12e..9f49a5b 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -135,6 +135,14 @@ interface AccountCompanyOption { contas: string[]; } +interface ReservaLineOption { + value: string; + label: string; + linha: string; + chip?: string; + usuario?: string; +} + interface CreateBatchLineDraft extends Partial { uid: number; linha: string; @@ -398,6 +406,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; loadingAccountCompanies = false; + createReservaLineOptions: ReservaLineOption[] = []; + loadingCreateReservaLines = false; + private createReservaLineLookup = new Map(); get contaEmpresaOptions(): string[] { return this.accountCompanies.map((x) => x.empresa); @@ -437,6 +448,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { docType: 'PF', docNumber: '', contaEmpresa: '', + reservaLineId: '', linha: '', chip: '', tipoDeChip: '', @@ -2002,6 +2014,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.createMode = 'NEW_CLIENT'; this.resetCreateModel(); this.createOpen = true; + void this.loadCreateReservaLines(); this.cdr.detectChanges(); } @@ -2022,6 +2035,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.syncContaEmpresaSelection(this.createModel); this.createOpen = true; + void this.loadCreateReservaLines(); this.cdr.detectChanges(); } @@ -2031,6 +2045,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { docType: 'PF', docNumber: '', contaEmpresa: '', + reservaLineId: '', linha: '', chip: '', tipoDeChip: '', @@ -2701,7 +2716,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private buildCreatePayload(model: any): CreateMobileLineRequest { this.calculateFinancials(model); - const { contaEmpresa: _contaEmpresa, uid: _uid, ...createModelPayload } = model; + const { contaEmpresa: _contaEmpresa, uid: _uid, reservaLineId: _reservaLineId, ...createModelPayload } = model; return { ...createModelPayload, @@ -2816,6 +2831,99 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.createModel.docNumber = value; } + private async loadCreateReservaLines(): Promise { + if (this.loadingCreateReservaLines) return; + this.loadingCreateReservaLines = true; + + try { + const pageSize = 500; + let page = 1; + const collected: ReservaLineOption[] = []; + + while (true) { + const params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)) + .set('skil', 'RESERVA'); + + const response = await firstValueFrom(this.http.get>(this.apiBase, { params })); + const items = Array.isArray(response?.items) ? response.items : []; + + for (const row of items) { + const id = (row?.id ?? '').toString().trim(); + const linha = (row?.linha ?? '').toString().trim(); + if (!id || !linha) continue; + collected.push({ + value: id, + label: `${row?.item ?? ''} • ${linha} • ${(row?.usuario ?? 'SEM USUÁRIO').toString()}`, + linha, + chip: (row?.chip ?? '').toString(), + usuario: (row?.usuario ?? '').toString() + }); + } + + const total = Number(response?.total ?? 0); + if (!items.length || (total > 0 && collected.length >= total) || items.length < pageSize) break; + page += 1; + } + + const seen = new Set(); + const unique = collected.filter((opt) => { + if (seen.has(opt.value)) return false; + seen.add(opt.value); + return true; + }); + + unique.sort((a, b) => a.linha.localeCompare(b.linha, 'pt-BR', { numeric: true, sensitivity: 'base' })); + + this.createReservaLineOptions = unique; + this.createReservaLineLookup = new Map(unique.map((opt) => [opt.value, opt])); + } catch { + this.createReservaLineOptions = []; + this.createReservaLineLookup.clear(); + await this.showToast('Erro ao carregar linhas da Reserva.'); + } finally { + this.loadingCreateReservaLines = false; + } + } + + onCreateReservaLineChange() { + const lineId = (this.createModel?.reservaLineId ?? '').toString().trim(); + if (!lineId) { + this.createModel.linha = ''; + return; + } + + const selected = this.createReservaLineLookup.get(lineId); + if (selected) { + this.createModel.linha = selected.linha ?? ''; + if (!String(this.createModel.chip ?? '').trim() && selected.chip) { + this.createModel.chip = selected.chip; + } + if (!String(this.createModel.usuario ?? '').trim() && selected.usuario) { + this.createModel.usuario = selected.usuario; + } + } + + this.http.get(`${this.apiBase}/${lineId}`).subscribe({ + next: (detail) => { + this.createModel.linha = (detail?.linha ?? this.createModel.linha ?? '').toString(); + if (!String(this.createModel.chip ?? '').trim() && detail?.chip) { + this.createModel.chip = detail.chip; + } + if (!String(this.createModel.tipoDeChip ?? '').trim() && detail?.tipoDeChip) { + this.createModel.tipoDeChip = detail.tipoDeChip; + } + if (!String(this.createModel.usuario ?? '').trim() && detail?.usuario) { + this.createModel.usuario = detail.usuario; + } + }, + error: () => { + // Mantém dados já carregados da lista. + } + }); + } + async saveCreate() { if (this.isCreateBatchMode) { await this.saveCreateBatch(); diff --git a/src/app/pages/system-provision-user/system-provision-user.html b/src/app/pages/system-provision-user/system-provision-user.html index 0bb650a..2ede469 100644 --- a/src/app/pages/system-provision-user/system-provision-user.html +++ b/src/app/pages/system-provision-user/system-provision-user.html @@ -34,19 +34,18 @@
- + [options]="tenantOptions" + labelKey="label" + valueKey="value" + formControlName="tenantId" + [disabled]="tenantsLoading || tenantOptions.length === 0" + [searchable]="true" + searchPlaceholder="Pesquisar cliente..." + [placeholder]="tenantsLoading ? 'Carregando clientes...' : 'Selecione um cliente...'" + > diff --git a/src/app/pages/system-provision-user/system-provision-user.ts b/src/app/pages/system-provision-user/system-provision-user.ts index b1045b5..95eb082 100644 --- a/src/app/pages/system-provision-user/system-provision-user.ts +++ b/src/app/pages/system-provision-user/system-provision-user.ts @@ -9,6 +9,7 @@ import { ValidationErrors, Validators, } from '@angular/forms'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { SysadminService, @@ -19,7 +20,7 @@ import { @Component({ selector: 'app-system-provision-user', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, ReactiveFormsModule, CustomSelectComponent], templateUrl: './system-provision-user.html', styleUrls: ['./system-provision-user.scss'], }) @@ -28,6 +29,7 @@ export class SystemProvisionUserPage implements OnInit { provisionForm: FormGroup; tenants: SystemTenantDto[] = []; + tenantOptions: Array<{ label: string; value: string }> = []; tenantsLoading = false; tenantsError = ''; @@ -71,11 +73,17 @@ export class SystemProvisionUserPage implements OnInit { this.tenants = (tenants || []).slice().sort((a, b) => (a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' }) ); + this.tenantOptions = this.tenants.map((tenant) => ({ + label: tenant.nomeOficial || tenant.tenantId, + value: tenant.tenantId, + })); this.tenantsLoading = false; this.syncTenantControlAvailability(); }, error: (err: HttpErrorResponse) => { this.tenantsLoading = false; + this.tenants = []; + this.tenantOptions = []; this.tenantsError = this.extractErrorMessage( err, 'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.'