diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 3c39d3d..0b219bf 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -21,6 +21,7 @@ import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
import { Historico } from './pages/historico/historico';
import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
+import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
export const routes: Routes = [
{ path: '', component: Home },
@@ -38,6 +39,7 @@ export const routes: Routes = [
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
+ { path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{
path: 'system/fornecer-usuario',
diff --git a/src/app/app.ts b/src/app/app.ts
index 11b8f61..0d6c4dc 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -29,6 +29,7 @@ export class AppComponent {
// ✅ rotas internas (LOGADO) que devem esconder footer
private readonly loggedPrefixes = [
'/geral',
+ '/solicitacoes-linhas',
'/mureg',
'/faturamento',
'/dadosusuarios',
diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html
index cc76c2f..4c1289c 100644
--- a/src/app/components/header/header.html
+++ b/src/app/components/header/header.html
@@ -548,6 +548,9 @@
Histórico
+
+ Solicitações
+
Dados PF/PJ
diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts
index 0d78ae7..ef45da7 100644
--- a/src/app/components/header/header.ts
+++ b/src/app/components/header/header.ts
@@ -93,6 +93,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/resumo',
'/parcelamentos',
'/historico',
+ '/solicitacoes',
'/perfil',
'/system',
];
diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html
index 6ba0955..8e06caa 100644
--- a/src/app/pages/geral/geral.html
+++ b/src/app/pages/geral/geral.html
@@ -1975,12 +1975,24 @@
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts
index 7e78101..bc3f3df 100644
--- a/src/app/pages/geral/geral.ts
+++ b/src/app/pages/geral/geral.ts
@@ -22,6 +22,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
+import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@@ -289,7 +290,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private planAutoFill: PlanAutoFillService,
private authService: AuthService,
private router: Router,
- private tenantSyncService: TenantSyncService
+ private tenantSyncService: TenantSyncService,
+ private solicitacoesLinhasService: SolicitacoesLinhasService
) {}
private readonly apiBase = (() => {
@@ -347,6 +349,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
financeOpen = false;
editOpen = false;
editSaving = false;
+ requestSaving = false;
createOpen = false;
createSaving = false;
@@ -858,6 +861,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.aparelhoReciboFile = null;
this.editSaving = false;
+ this.requestSaving = false;
this.createSaving = false;
this.editModel = null;
@@ -1865,6 +1869,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
async onEditar(r: LineRow) {
this.editOpen = true;
this.editSaving = false;
+ this.requestSaving = false;
this.editModel = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
@@ -1942,11 +1947,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async saveEdit() {
- if (!this.editingId || !this.editModel) return;
+ if (!this.editingId || !this.editModel || this.requestSaving) return;
this.editSaving = true;
const editingId = this.editingId;
const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile);
+ const franquiaLineAtual = this.toNullableNumber(this.editModel.franquiaLine);
+ const franquiaLineSolicitada = this.toNullableNumber(this.editModel.franquiaLineSolicitada);
+ const shouldRequestFranquia =
+ this.isClientRestricted && !this.nullableNumberEquals(franquiaLineAtual, franquiaLineSolicitada);
let payload: UpdateMobileLineRequest;
if (this.isClientRestricted) {
@@ -1954,15 +1963,21 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
item: this.toInt(this.editModel.item),
usuario: (this.editModel.usuario ?? '').toString(),
centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(),
+ setorNome: (this.editModel.setorNome ?? '').toString(),
aparelhoId: (this.editModel.aparelhoId ?? null) as string | null,
aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(),
aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(),
- aparelhoImei: (this.editModel.aparelhoImei ?? '').toString()
+ aparelhoImei: (this.editModel.aparelhoImei ?? '').toString(),
+ franquiaLine: franquiaLineAtual
};
} else {
this.calculateFinancials(this.editModel);
- const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel;
+ const {
+ contaEmpresa: _contaEmpresa,
+ franquiaLineSolicitada: _franquiaLineSolicitada,
+ ...editModelPayload
+ } = this.editModel;
payload = {
...editModelPayload,
@@ -2004,12 +2019,48 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return;
}
+ if (shouldRequestFranquia) {
+ try {
+ await firstValueFrom(this.solicitacoesLinhasService.create({
+ lineId: editingId,
+ tipoSolicitacao: 'alteracao-franquia',
+ franquiaLineNova: franquiaLineSolicitada
+ }));
+ } catch (err) {
+ this.editSaving = false;
+ this.closeAllModals();
+ const msg =
+ (err as HttpErrorResponse)?.error?.message
+ || 'Registro atualizado, mas falhou ao enviar a solicitação de franquia.';
+ await this.showToast(msg);
+
+ if (this.isGroupMode && this.expandedGroup) {
+ const term = (this.searchTerm ?? '').trim();
+ const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
+ this.fetchGroupLines(this.expandedGroup, useTerm);
+ this.loadGroups();
+ this.loadKpis();
+ } else {
+ this.refreshData();
+ }
+ return;
+ }
+ }
+
this.editSaving = false;
// fecha e limpa overlay SEMPRE
this.closeAllModals();
- await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!');
+ if (shouldRequestFranquia) {
+ await this.showToast(
+ shouldUploadAttachments
+ ? 'Registro e anexos atualizados! Solicitação de franquia enviada.'
+ : 'Registro atualizado! Solicitação de franquia enviada.'
+ );
+ } else {
+ await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!');
+ }
if (this.isGroupMode && this.expandedGroup) {
const term = (this.searchTerm ?? '').trim();
@@ -2029,6 +2080,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
});
}
+ async requestLineBlock() {
+ if (!this.editingId || this.requestSaving) return;
+
+ this.requestSaving = true;
+ try {
+ await firstValueFrom(this.solicitacoesLinhasService.create({
+ lineId: this.editingId,
+ tipoSolicitacao: 'bloqueio'
+ }));
+
+ this.requestSaving = false;
+ this.closeAllModals();
+ await this.showToast('Solicitação de bloqueio enviada.');
+ } catch (err) {
+ this.requestSaving = false;
+ const msg = (err as HttpErrorResponse)?.error?.message || 'Erro ao enviar solicitação de bloqueio.';
+ await this.showToast(msg);
+ }
+ }
+
onAparelhoNotaFiscalSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null;
@@ -3618,6 +3689,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
valorContratoVivo: d.valorContratoVivo ?? null,
franquiaLine: d.franquiaLine ?? null,
+ franquiaLineSolicitada: d.franquiaLine ?? null,
franquiaGestao: d.franquiaGestao ?? null,
locacaoAp: d.locacaoAp ?? null,
valorContratoLine: d.valorContratoLine ?? null,
@@ -3660,6 +3732,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
+ private nullableNumberEquals(a: number | null, b: number | null): boolean {
+ if (a === null || b === null) return a === b;
+ return Math.abs(a - b) < 0.000001;
+ }
+
private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim();
if (!v) return list;
diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html
new file mode 100644
index 0000000..73098c7
--- /dev/null
+++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMsg }}
+
+
+
+ Nenhuma solicitação encontrada.
+
+
+
0">
+
+
+
+
+
+
+
+
+
+
+
+
+ | DATA |
+ CLIENTE |
+ LINHA |
+ USUARIO LINHA |
+ TIPO |
+ FRANQUIA ANTES |
+ FRANQUIA DEPOIS |
+ DESCRICAO |
+
+
+
+
+ |
+
+ {{ formatDate(item.createdAt) }}
+ {{ formatTime(item.createdAt) }}
+
+ |
+ {{ item.tenantNome || '-' }} |
+ {{ item.linha || '-' }} |
+ {{ item.usuarioLinha || '-' }} |
+
+ {{ tipoLabel(item.tipoSolicitacao) }}
+ |
+ {{ formatFranquia(item.franquiaLineAtual) }} |
+ {{ formatFranquia(item.franquiaLineNova) }} |
+
+ {{ descricao(item) }}
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss
new file mode 100644
index 0000000..f4e16fb
--- /dev/null
+++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss
@@ -0,0 +1,368 @@
+:host {
+ --brand: #e33dcf;
+ --text: #111214;
+ --card-bg: rgba(255, 255, 255, 0.88);
+ --card-border: 1px solid rgba(227, 61, 207, 0.14);
+ --stroke: rgba(16, 24, 40, 0.08);
+ --soft: rgba(17, 18, 20, 0.64);
+
+ display: block;
+ color: var(--text);
+}
+
+.solicitacoes-page {
+ min-height: 100vh;
+ padding: 0 0 18px;
+ background:
+ radial-gradient(920px 420px at 15% 8%, rgba(227, 61, 207, 0.1), transparent 60%),
+ radial-gradient(860px 420px at 85% 20%, rgba(3, 15, 170, 0.07), transparent 60%),
+ linear-gradient(180deg, #ffffff 0%, #f5f6fb 70%);
+}
+
+.container-geral-responsive {
+ width: calc(100vw - 2px);
+ max-width: none;
+ margin: 16px auto 24px;
+ position: relative;
+ z-index: 1;
+}
+
+.geral-card {
+ border-radius: 22px;
+ overflow: hidden;
+ background: var(--card-bg);
+ border: var(--card-border);
+ backdrop-filter: blur(8px);
+ box-shadow: 0 18px 40px rgba(17, 18, 20, 0.1);
+ display: flex;
+ flex-direction: column;
+ min-height: 64vh;
+}
+
+.geral-header {
+ padding: 16px 22px 14px;
+ border-bottom: 1px solid var(--stroke);
+ background: linear-gradient(180deg, rgba(227, 61, 207, 0.05), rgba(255, 255, 255, 0.16));
+}
+
+.header-row-top {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ gap: 12px;
+ align-items: center;
+}
+
+.title-badge {
+ justify-self: start;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(227, 61, 207, 0.2);
+ background: rgba(255, 255, 255, 0.8);
+ font-weight: 800;
+ font-size: 12px;
+}
+
+.header-title {
+ justify-self: center;
+ text-align: center;
+}
+
+.title {
+ font-size: 24px;
+ font-weight: 900;
+ letter-spacing: -0.01em;
+}
+
+.subtitle {
+ color: var(--soft);
+ font-weight: 600;
+}
+
+.header-actions {
+ justify-self: end;
+}
+
+.btn-brand {
+ background: var(--brand);
+ border-color: var(--brand);
+ color: #fff;
+ font-weight: 800;
+}
+
+.controls {
+ display: grid;
+ grid-template-columns: minmax(320px, 1.3fr) auto auto;
+ align-items: center;
+ gap: 14px;
+}
+
+.search-group {
+ max-width: 700px;
+ width: 100%;
+ border-radius: 13px;
+ border: 1px solid rgba(16, 24, 40, 0.16);
+ overflow: hidden;
+ box-shadow: 0 4px 10px rgba(16, 24, 40, 0.05);
+ background: #fff;
+
+ .input-group-text,
+ .form-control,
+ .btn-clear {
+ border: none;
+ }
+
+ .input-group-text {
+ color: rgba(17, 18, 20, 0.55);
+ }
+
+ .form-control {
+ font-size: 13px;
+ }
+}
+
+.geral-body {
+ flex: 1;
+ padding: 0 4px 10px;
+ overflow: hidden;
+}
+
+.table-wrap {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ min-height: clamp(260px, 44vh, 500px);
+ max-height: 60vh;
+ border: 1px solid rgba(16, 24, 40, 0.1);
+ border-radius: 14px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.96) 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
+}
+
+.table-modern {
+ width: 100%;
+ min-width: 0;
+ table-layout: fixed;
+}
+
+.table-modern col.col-date { width: 9%; }
+.table-modern col.col-cliente { width: 14%; }
+.table-modern col.col-linha { width: 10%; }
+.table-modern col.col-usuario { width: 15%; }
+.table-modern col.col-tipo { width: 14%; }
+.table-modern col.col-franquia { width: 12%; }
+.table-modern col.col-descricao { width: 14%; }
+
+.table-modern thead th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #f6f8fc;
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: rgba(17, 18, 20, 0.74);
+ padding: 10px 12px;
+ border-bottom: 1px solid #e4e9f3;
+ white-space: normal;
+ word-break: break-word;
+ line-height: 1.2;
+}
+
+.table-modern thead th:nth-child(6),
+.table-modern thead th:nth-child(7),
+.table-modern tbody td:nth-child(6),
+.table-modern tbody td:nth-child(7) {
+ min-width: 0;
+ text-align: center;
+}
+
+.table-modern tbody td {
+ font-size: 13px;
+ padding: 11px 12px;
+ border-bottom: 1px solid #ebeff6;
+ vertical-align: middle;
+ white-space: normal;
+ word-break: break-word;
+ color: #1a1c20;
+}
+
+.table-modern tbody tr:nth-child(even) {
+ background: rgba(243, 247, 253, 0.56);
+}
+
+.table-modern tbody tr:hover {
+ background: #eef5ff;
+}
+
+.date-cell {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.15;
+ gap: 3px;
+}
+
+.date-main {
+ font-weight: 700;
+ color: #1a1c20;
+}
+
+.date-sub {
+ font-size: 11px;
+ color: rgba(17, 18, 20, 0.55);
+}
+
+.mono-cell {
+ font-family: "JetBrains Mono", "Consolas", "Monaco", monospace;
+ font-variant-numeric: tabular-nums;
+ font-size: 12px;
+ font-weight: 700;
+ white-space: normal;
+ word-break: break-all;
+}
+
+.cell-ellipsis {
+ overflow: visible;
+ text-overflow: unset;
+ white-space: normal;
+}
+
+.type-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ padding: 5px 10px;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1;
+ border: 1px solid transparent;
+ white-space: nowrap;
+}
+
+.type-badge--franquia {
+ color: #1e4e8f;
+ background: rgba(44, 121, 232, 0.14);
+ border-color: rgba(44, 121, 232, 0.24);
+}
+
+.type-badge--bloqueio {
+ color: #8f2f2f;
+ background: rgba(221, 74, 74, 0.14);
+ border-color: rgba(221, 74, 74, 0.25);
+}
+
+.type-badge--default {
+ color: #5b6173;
+ background: rgba(118, 127, 154, 0.14);
+ border-color: rgba(118, 127, 154, 0.24);
+}
+
+.franquia-cell {
+ text-align: center;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.message-cell {
+ white-space: normal;
+}
+
+.message-text {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ line-height: 1.32;
+ color: rgba(17, 18, 20, 0.8);
+ word-break: break-word;
+}
+
+.table-summary {
+ justify-self: end;
+ font-size: 12px;
+ color: rgba(17, 18, 20, 0.64);
+ font-weight: 600;
+
+ strong {
+ color: #111214;
+ font-weight: 800;
+ }
+}
+
+.empty-group {
+ padding: 36px 18px;
+ text-align: center;
+ color: rgba(17, 18, 20, 0.64);
+}
+
+.geral-footer {
+ display: none;
+}
+
+.table-wrap::-webkit-scrollbar {
+ height: 10px;
+ width: 10px;
+}
+
+.table-wrap::-webkit-scrollbar-track {
+ background: rgba(17, 18, 20, 0.06);
+}
+
+.table-wrap::-webkit-scrollbar-thumb {
+ background: rgba(103, 114, 143, 0.45);
+ border-radius: 999px;
+}
+
+@media (max-width: 1200px) {
+ .controls {
+ grid-template-columns: 1fr auto;
+ }
+
+ .table-summary {
+ grid-column: 1 / -1;
+ justify-self: start;
+ }
+
+ .table-modern col.col-date { width: 10%; }
+ .table-modern col.col-cliente { width: 13%; }
+ .table-modern col.col-linha { width: 9%; }
+ .table-modern col.col-usuario { width: 14%; }
+ .table-modern col.col-tipo { width: 14%; }
+ .table-modern col.col-franquia { width: 13%; }
+ .table-modern col.col-descricao { width: 14%; }
+
+ .table-modern thead th,
+ .table-modern tbody td {
+ padding: 9px 8px;
+ font-size: 11px;
+ }
+}
+
+@media (max-width: 992px) {
+ .header-row-top {
+ grid-template-columns: 1fr;
+ text-align: center;
+ }
+
+ .title-badge,
+ .header-title,
+ .header-actions {
+ justify-self: center;
+ }
+
+ .controls {
+ grid-template-columns: 1fr;
+ align-items: start;
+ }
+
+ .message-text {
+ -webkit-line-clamp: 3;
+ }
+}
diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts
new file mode 100644
index 0000000..a52be9f
--- /dev/null
+++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts
@@ -0,0 +1,189 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { CustomSelectComponent } from '../../components/custom-select/custom-select';
+import { SolicitacaoLinhaDto, SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
+
+@Component({
+ selector: 'app-solicitacoes-linhas',
+ standalone: true,
+ imports: [CommonModule, FormsModule, CustomSelectComponent],
+ templateUrl: './solicitacoes-linhas.html',
+ styleUrls: ['./solicitacoes-linhas.scss'],
+})
+export class SolicitacoesLinhas implements OnInit, OnDestroy {
+ items: SolicitacaoLinhaDto[] = [];
+ loading = false;
+ errorMsg = '';
+
+ page = 1;
+ pageSize = 20;
+ pageSizeOptions = [10, 20, 50, 100];
+ total = 0;
+
+ search = '';
+ private searchTimer: ReturnType
| null = null;
+
+ constructor(private readonly solicitacoesService: SolicitacoesLinhasService) {}
+
+ ngOnInit(): void {
+ this.fetch(1);
+ }
+
+ ngOnDestroy(): void {
+ if (this.searchTimer) {
+ clearTimeout(this.searchTimer);
+ this.searchTimer = null;
+ }
+ }
+
+ refresh(): void {
+ this.fetch();
+ }
+
+ onSearchChange(): void {
+ if (this.searchTimer) {
+ clearTimeout(this.searchTimer);
+ }
+
+ this.searchTimer = setTimeout(() => {
+ this.page = 1;
+ this.fetch();
+ }, 300);
+ }
+
+ clearSearch(): void {
+ this.search = '';
+ this.page = 1;
+ this.fetch();
+ }
+
+ onPageSizeChange(): void {
+ this.page = 1;
+ this.fetch();
+ }
+
+ goToPage(pageNumber: number): void {
+ this.page = Math.max(1, Math.min(this.totalPages, pageNumber));
+ this.fetch();
+ }
+
+ get totalPages(): number {
+ return Math.ceil((this.total || 0) / this.pageSize) || 1;
+ }
+
+ get pageNumbers(): number[] {
+ const total = this.totalPages;
+ const current = this.page;
+ const max = 5;
+ let start = Math.max(1, current - 2);
+ let end = Math.min(total, start + (max - 1));
+ start = Math.max(1, end - (max - 1));
+
+ const pages: number[] = [];
+ for (let i = start; i <= end; i++) pages.push(i);
+ return pages;
+ }
+
+ get pageStart(): number {
+ return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
+ }
+
+ get pageEnd(): number {
+ if (this.total === 0) return 0;
+ return Math.min(this.page * this.pageSize, this.total);
+ }
+
+ private parseDate(value?: string | null): Date | null {
+ if (!value) return null;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? null : d;
+ }
+
+ formatDateTime(value?: string | null): string {
+ const d = this.parseDate(value);
+ if (!d) return '-';
+ return d.toLocaleString('pt-BR');
+ }
+
+ formatDate(value?: string | null): string {
+ const d = this.parseDate(value);
+ if (!d) return '-';
+ return d.toLocaleDateString('pt-BR');
+ }
+
+ formatTime(value?: string | null): string {
+ const d = this.parseDate(value);
+ if (!d) return '--:--';
+ return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
+ }
+
+ formatFranquiaValor(value?: number | null): string {
+ if (value === null || value === undefined) return '-';
+ return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
+ }
+
+ formatFranquia(value?: number | null): string {
+ const formatted = this.formatFranquiaValor(value);
+ return formatted === '-' ? '-' : `${formatted} GB`;
+ }
+
+ tipoLabel(value?: string | null): string {
+ const v = (value ?? '').toString().trim().toUpperCase();
+ if (v === 'ALTERACAO_FRANQUIA') return 'Alteração de franquia';
+ if (v === 'BLOQUEIO') return 'Bloqueio';
+ return v || '-';
+ }
+
+ tipoBadgeClass(value?: string | null): string {
+ const v = (value ?? '').toString().trim().toUpperCase();
+ if (v === 'ALTERACAO_FRANQUIA') return 'type-badge type-badge--franquia';
+ if (v === 'BLOQUEIO') return 'type-badge type-badge--bloqueio';
+ return 'type-badge type-badge--default';
+ }
+
+ trackBySolicitacao(_: number, item: SolicitacaoLinhaDto): string {
+ return item.id;
+ }
+
+ descricao(item: SolicitacaoLinhaDto): string {
+ const tipo = (item.tipoSolicitacao ?? '').toString().trim().toUpperCase();
+ const linha = (item.linha ?? '').toString().trim();
+
+ if (tipo === 'ALTERACAO_FRANQUIA') {
+ return `Mudanca de franquia de ${this.formatFranquiaValor(item.franquiaLineAtual)} para ${this.formatFranquiaValor(item.franquiaLineNova)}`;
+ }
+
+ if (tipo === 'BLOQUEIO') {
+ return `Bloqueio da linha ${linha || '-'}`;
+ }
+
+ return (item.mensagem ?? '').toString().trim() || '-';
+ }
+
+ private fetch(goToPage?: number): void {
+ if (goToPage) this.page = goToPage;
+ this.loading = true;
+ this.errorMsg = '';
+
+ this.solicitacoesService
+ .list({
+ page: this.page,
+ pageSize: this.pageSize,
+ search: this.search?.trim() || undefined,
+ })
+ .subscribe({
+ next: (res) => {
+ this.items = res.items || [];
+ this.total = res.total || 0;
+ this.page = res.page || this.page;
+ this.pageSize = res.pageSize || this.pageSize;
+ this.loading = false;
+ },
+ error: () => {
+ this.loading = false;
+ this.errorMsg = 'Não foi possível carregar as solicitações.';
+ },
+ });
+ }
+}
diff --git a/src/app/services/solicitacoes-linhas.service.ts b/src/app/services/solicitacoes-linhas.service.ts
new file mode 100644
index 0000000..d3982ff
--- /dev/null
+++ b/src/app/services/solicitacoes-linhas.service.ts
@@ -0,0 +1,61 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { environment } from '../../environments/environment';
+
+export interface PagedResult {
+ page: number;
+ pageSize: number;
+ total: number;
+ items: T[];
+}
+
+export interface SolicitacaoLinhaDto {
+ id: string;
+ tenantId: string;
+ tenantNome?: string | null;
+ mobileLineId?: string | null;
+ linha?: string | null;
+ usuarioLinha?: string | null;
+ tipoSolicitacao: string;
+ franquiaLineAtual?: number | null;
+ franquiaLineNova?: number | null;
+ solicitanteNome?: string | null;
+ mensagem: string;
+ status: string;
+ createdAt: string;
+}
+
+export interface SolicitacaoLinhaCreatePayload {
+ lineId: string;
+ tipoSolicitacao: 'alteracao-franquia' | 'bloqueio';
+ franquiaLineNova?: number | null;
+}
+
+@Injectable({ providedIn: 'root' })
+export class SolicitacoesLinhasService {
+ private readonly baseUrl: string;
+
+ constructor(private readonly http: HttpClient) {
+ const raw = (environment.apiUrl || '').replace(/\/+$/, '');
+ const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseUrl = `${apiBase}/solicitacoes-linhas`;
+ }
+
+ list(params?: { page?: number; pageSize?: number; search?: string }): Observable> {
+ let httpParams = new HttpParams()
+ .set('page', String(params?.page ?? 1))
+ .set('pageSize', String(params?.pageSize ?? 20));
+
+ const search = (params?.search ?? '').trim();
+ if (search) {
+ httpParams = httpParams.set('search', search);
+ }
+
+ return this.http.get>(this.baseUrl, { params: httpParams });
+ }
+
+ create(payload: SolicitacaoLinhaCreatePayload): Observable {
+ return this.http.post(this.baseUrl, payload);
+ }
+}