-
-
-
-
- Adicionais
-
-
-
-
-
-
- {{ additionalModeLabel }}
-
-
- {{ label }}
-
-
-
-
-
-
-
-
-
Modo
-
-
- Todos os adicionais
-
-
- Com adicionais
-
-
- Sem adicionais
-
-
+
+
+
-
-
Serviços
-
-
- {{ svc.label }}
-
+
+
+
+
+
+
0"
+ (click)="toggleClientMenu()"
+ [disabled]="loading">
+
+
+
+ Clientes
+
+
+
+ 0">
+
+
+ {{ client }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Todos os Clientes
+
+
+
+
+
+
-
@@ -275,6 +353,26 @@
+
+
+
+ Selecionadas: {{ batchStatusSelectionCount }}
+
+
+ Bloquear em lote
+
+
+ Desbloquear em lote
+
+
@@ -312,6 +410,20 @@
{{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }}
+
+ Bloquear
+
+
+ Desbloquear
+
Enviar p/ Reserva ({{ reservaSelectedCount }})
-
+
Adicionar Linha
@@ -398,7 +510,7 @@
-
+
@@ -517,7 +629,7 @@
-
+
@@ -557,1641 +669,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Unitário
-
-
- Lote de Linhas
-
-
-
- Modo lote focado em volume: use a grade para preencher rapidamente e abra o painel lateral de Detalhes
- da linha quando precisar completar campos de contrato, datas e financeiro.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Total: {{ createBatchValidationSummary.total }}
- Válidas: {{ createBatchValidationSummary.valid }}
- 0">
- Inválidas: {{ createBatchValidationSummary.invalid }}
-
- 0">
- Duplicadas: {{ createBatchValidationSummary.duplicates }}
-
-
-
-
0"
- [class.is-ok]="createBatchValidationSummary.total > 0 && createBatchValidationSummary.invalid === 0"
- >
- 0 ? 'bi-exclamation-triangle-fill' : 'bi-check-circle-fill'">
- {{ batchValidationMessage }}
-
-
-
- O cliente é definido pelo contexto do cadastro (novo cliente ou cliente existente). Na grade você preenche os
- campos rápidos e usa Detalhes para completar os dados obrigatórios e opcionais da linha
- (Conta, Plano Contrato, Status, Datas , financeiro, etc.). Esses campos são por linha
- e podem ser diferentes entre linhas.
-
-
-
-
-
-
- Remover Inválidas
-
-
-
- Limpar Lote
-
-
-
-
- Nenhuma linha no lote ainda. Use a importação por planilha acima para pré-visualizar e
- carregar as linhas na grade.
-
-
-
0">
-
-
-
-
-
- Após carregar o lote pela importação da planilha , selecione uma linha e clique em
- Detalhes para preencher `Contrato`, `Datas`, `Financeiro` e demais campos obrigatórios do
- cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Selecione uma linha e clique em Detalhes para preencher os campos completos da linha.
-
Os obrigatórios do cadastro unitário também são validados aqui (conta, plano, status, datas, etc.).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- As linhas selecionadas serão movidas para a Reserva e ficarão disponíveis para reatribuição.
-
-
-
Cliente: {{ expandedGroup || '-' }}
-
Selecionadas: {{ reservaSelectedCount }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ITEM
- LINHA
- CHIP (ICCID)
- USUÁRIO
-
-
-
-
- Nenhuma linha selecionada.
-
-
- {{ r.item }}
- {{ r.linha || '-' }}
- {{ r.chip || '-' }}
- {{ r.usuario || '-' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ITEM
- LINHA
- CHIP (ICCID)
- USUÁRIO
-
-
-
-
- Nenhuma linha selecionada.
-
-
- {{ r.item }}
- {{ r.linha || '-' }}
- {{ r.chip || '-' }}
- {{ r.usuario || '-' }}
-
-
-
-
-
- Somente linhas que ainda estiverem aptas na Reserva serão atribuídas. O backend retorna sucesso/erro por linha.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Linha
- {{ detailData.linha || '-' }}
-
-
- Cliente
- {{ detailData.cliente || '-' }}
-
-
- Usuário
- {{ detailData.usuario || '-' }}
-
-
- Centro de Custos
- {{ detailData.centroDeCustos || '-' }}
-
-
- Setor
- {{ detailData.setorNome || '-' }}
-
-
- Item
- {{ detailData.item }}
-
-
- Chip (ICCID)
- {{ detailData.chip || '-' }}
-
-
- Tipo de Chip
- {{ detailData.tipoDeChip || '-' }}
-
-
- Franquia Line
- {{ formatFranquia(detailData.franquiaLine) }}
-
-
-
-
-
-
-
-
-
-
-
-
- Aparelho
- {{ detailData.aparelhoNome || '-' }}
-
-
- Cor do Aparelho
- {{ detailData.aparelhoCor || '-' }}
-
-
- IMEI
- {{ detailData.aparelhoImei || '-' }}
-
-
- Nota Fiscal (Anexo)
-
-
-
- Baixar arquivo
-
-
-
- -
-
-
-
-
- Recibo (Anexo)
-
-
-
- Baixar arquivo
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tipo (Skil)
- {{ detailData.skil || '-' }}
-
-
- Cedente
- {{ detailData.cedente || '-' }}
-
-
- Solicitante
- {{ detailData.solicitante || '-' }}
-
-
-
-
-
-
-
-
-
-
- Entrega Operadora
- {{ formatDateBr(detailData.dataEntregaOpera) }}
-
-
- Entrega Cliente
- {{ formatDateBr(detailData.dataEntregaCliente) }}
-
-
- Data Bloqueio
- {{ formatDateBr(detailData.dataBloqueio) }}
-
-
- Efetivação Serviço
- {{ formatDateBr(detailData.dtEfetivacaoServico) }}
-
-
- Término Fidelização
- {{ formatDateBr(detailData.dtTerminoFidelizacao) }}
-
-
-
-
-
-
-
-
-
-
-
-
- Plano Contratado
- {{ detailData.planoContrato || '-' }}
-
-
- Conta
- {{ detailData.conta || '-' }}
-
-
- Vencimento
- {{ detailData.vencConta || '-' }}
-
-
-
Status Atual
-
- {{ statusLabel(detailData.status) }}
-
-
-
- Modalidade
- {{ detailData.modalidade || '-' }}
-
-
-
-
-
-
-
-
-
-
- Carregando detalhes...
-
-
-
-
-
-
-
-
-
-
-
-
-
Franquia {{ formatFranquia(financeData.franquiaVivo) }}
-
Valor Plano {{ formatMoney(financeData.valorPlanoVivo) }}
-
Gestão Voz/Dados {{ formatMoney(financeData.gestaoVozDados) }}
-
Skeelo {{ formatMoney(financeData.skeelo) }}
-
Vivo News+ {{ formatMoney(financeData.vivoNewsPlus) }}
-
Travel Mundo {{ formatMoney(financeData.vivoTravelMundo) }}
-
Gestão Disp. {{ formatMoney(financeData.vivoGestaoDispositivo) }}
-
Vivo Sync {{ formatMoney(financeData.vivoSync) }}
-
-
Total Vivo {{ formatMoney(financeData.valorContratoVivo) }}
-
-
-
-
-
-
-
Franquia Line {{ formatFranquia(financeData.franquiaLine) }}
-
Franquia Gestão {{ formatFranquia(financeData.franquiaGestao) }}
-
Locação Ap. {{ formatMoney(financeData.locacaoAp) }}
-
-
Total Line {{ formatMoney(financeData.valorContratoLine) }}
-
-
-
-
-
-
- Desconto
- {{ formatMoney(financeData.desconto) }}
-
-
-
- Lucro Estimado
- {{ formatMoney(financeData.lucro) }}
-
-
-
-
-
- Carregando financeiro...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Carregando...
-
-
-
-
+
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss
index e8dda84..2056380 100644
--- a/src/app/pages/geral/geral.scss
+++ b/src/app/pages/geral/geral.scss
@@ -123,11 +123,35 @@
.btn-glass { border-radius: 12px; font-weight: 900; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(3, 15, 170, 0.25); color: var(--blue); &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } }
/* Filtros e Multi-Select */
-.filters-row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
+.filters-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.filters-row {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 0;
+ position: relative;
+ z-index: 30;
+ overflow: visible;
+}
+
+.filters-row-top {
+ justify-content: center;
+}
+
+.filters-row-bottom {
+ justify-content: center;
+}
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); }
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
-.client-filter-wrap { position: relative; }
+.client-filter-wrap { position: relative; z-index: 40; }
.btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } }
.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
.client-chip { display: inline-flex; align-items: center; background: rgba(227, 61, 207, 0.1); color: var(--brand); border: 1px solid rgba(227, 61, 207, 0.2); border-radius: 6px; padding: 2px 6px; font-size: 0.75rem; font-weight: 800; cursor: default; user-select: none; }
@@ -140,6 +164,34 @@
.additional-filter-wrap {
position: relative;
+ z-index: 40;
+}
+
+.operadora-empresa-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: 50;
+}
+
+.filter-select-box {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 190px;
+ position: relative;
+ z-index: 50;
+}
+
+.filter-select-label {
+ font-size: 0.66rem;
+ font-weight: 900;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: rgba(17, 18, 20, 0.58);
+ padding-left: 2px;
}
.btn-additional-filter {
@@ -249,6 +301,18 @@
}
}
+@media (max-width: 768px) {
+ .operadora-empresa-filters {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .filter-select-box {
+ flex: 1 1 220px;
+ min-width: 0;
+ }
+}
+
/* KPIs */
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
.geral-kpis.geral-kpis-client {
@@ -273,6 +337,29 @@
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.search-group { max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease; &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); } .input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; padding-right: 8px; display: flex; align-items: center; i { font-size: 1rem; } } .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } &:focus { outline: none; } } .btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; display: flex; align-items: center; cursor: pointer; transition: color 0.2s; &:hover { color: #dc3545; } i { font-size: 1rem; } } }
.page-size { margin-left: auto; @media (max-width: 500px) { margin-left: 0; width: 100%; justify-content: space-between; } }
+.batch-status-tools {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-left: auto;
+
+ @media (max-width: 900px) {
+ margin-left: 0;
+ width: 100%;
+ }
+}
+.batch-status-count {
+ font-size: 0.75rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: rgba(17, 18, 20, 0.62);
+ padding: 4px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(17, 18, 20, 0.12);
+ background: rgba(255, 255, 255, 0.8);
+}
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass { background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(17, 18, 20, 0.15); border-radius: 12px; color: var(--blue); font-weight: 800; font-size: 0.9rem; text-align: left; padding: 8px 36px 8px 14px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); cursor: pointer; transition: all 0.2s ease; width: 100%; &:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1); } &:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); } }
.select-icon { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); font-size: 0.75rem; transition: transform 0.2s ease; }
@@ -500,8 +587,6 @@
/* ========================================================== */
/* 8. MODALS E FORMULÁRIOS COMPLETOS (RESTAURADOS ✅) */
/* ========================================================== */
-.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
-.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@@ -581,6 +666,140 @@
padding: 20px 22px;
}
}
+.modal-card.modal-batch-status {
+ width: min(1120px, 96vw);
+ max-height: 92vh;
+
+ .modal-header {
+ padding: 18px 22px 16px;
+ align-items: flex-start;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ .modal-title {
+ flex: 1 1 320px;
+ min-width: 0;
+ line-height: 1.2;
+ }
+
+ .batch-status-header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-left: auto;
+ }
+
+ .details-dashboard {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ align-items: start;
+
+ @media (max-width: 980px) {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+ }
+
+ .modal-body {
+ padding: 18px 22px 22px;
+ }
+
+ .detail-box {
+ height: 100%;
+ }
+
+ .box-body {
+ padding: 14px 16px 16px;
+ }
+
+ .form-grid {
+ gap: 14px 16px;
+ }
+
+ .form-field {
+ gap: 8px;
+ }
+
+ .reserva-confirmation-pills {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ margin-top: 2px;
+
+ .summary-pill {
+ margin: 0;
+ white-space: normal;
+ line-height: 1.25;
+ }
+ }
+
+ .batch-status-note {
+ display: block;
+ margin-top: 10px;
+ font-size: 0.8rem;
+ line-height: 1.4;
+ color: rgba(17, 18, 20, 0.58);
+ }
+
+ @media (max-width: 980px) {
+ .modal-header {
+ padding: 16px 18px 14px;
+ }
+
+ .batch-status-header-actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+
+ .modal-body {
+ padding: 16px 18px 18px;
+ }
+
+ .form-grid {
+ gap: 12px;
+ }
+ }
+
+ @media (max-width: 640px) {
+ .modal-header {
+ padding: 14px 14px 12px;
+ }
+
+ .modal-title {
+ font-size: 1rem;
+ gap: 10px;
+ }
+
+ .batch-status-header-actions {
+ justify-content: stretch;
+
+ .btn {
+ flex: 1 1 140px;
+ }
+ }
+
+ .modal-body {
+ padding: 14px;
+ }
+
+ .box-body {
+ padding: 12px;
+ }
+
+ .reserva-confirmation-pills {
+ gap: 8px;
+
+ .summary-pill {
+ width: 100%;
+ justify-content: flex-start;
+ }
+ }
+ }
+}
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
@@ -711,3 +930,421 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
.summary-pill { font-size: 0.72rem; }
.batch-validation-banner { align-items: flex-start; }
}
+
+.modal-mve-audit {
+ width: min(1320px, calc(100vw - 40px));
+ max-width: min(1320px, calc(100vw - 40px));
+ max-height: calc(100vh - 48px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.mve-audit-body {
+ display: grid;
+ gap: 16px;
+ overflow: auto;
+ position: relative;
+}
+
+.mve-intro-card,
+.mve-upload-card {
+ background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,249,252,0.98));
+ border: 1px solid rgba(17,18,20,0.08);
+ border-radius: 18px;
+ padding: 18px;
+ box-shadow: 0 16px 32px rgba(17,18,20,0.05);
+}
+
+.mve-intro-card {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+}
+
+.mve-intro-title {
+ font-size: 1rem;
+ font-weight: 900;
+ color: var(--text);
+}
+
+.mve-intro-text {
+ margin-top: 6px;
+ color: rgba(17,18,20,0.68);
+ max-width: 760px;
+ line-height: 1.45;
+}
+
+.mve-intro-meta,
+.mve-summary-notes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.meta-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ background: rgba(3,15,170,0.06);
+ color: rgba(17,18,20,0.76);
+ font-size: 0.76rem;
+ font-weight: 800;
+ border: 1px solid rgba(3,15,170,0.08);
+
+ &.accent {
+ background: rgba(12,132,78,0.08);
+ border-color: rgba(12,132,78,0.16);
+ color: #0c6c43;
+ }
+}
+
+.mve-section-title {
+ font-size: 0.94rem;
+ font-weight: 900;
+ color: var(--text);
+ margin-bottom: 12px;
+}
+
+.mve-upload-zone {
+ display: grid;
+ gap: 8px;
+ justify-items: center;
+ text-align: center;
+ border: 1.5px dashed rgba(3,15,170,0.22);
+ border-radius: 18px;
+ padding: 28px 18px;
+ background:
+ radial-gradient(circle at top right, rgba(3,15,170,0.08), transparent 36%),
+ linear-gradient(180deg, rgba(255,255,255,0.96), rgba(243,247,255,0.92));
+ cursor: pointer;
+ transition: border-color 0.18s ease, transform 0.18s ease;
+
+ &:hover {
+ border-color: rgba(3,15,170,0.38);
+ transform: translateY(-1px);
+ }
+
+ input {
+ display: none;
+ }
+}
+
+.upload-icon {
+ width: 54px;
+ height: 54px;
+ border-radius: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(3,15,170,0.08);
+ color: var(--blue);
+ font-size: 1.4rem;
+}
+
+.upload-title {
+ font-size: 0.96rem;
+ font-weight: 900;
+ color: var(--text);
+}
+
+.upload-subtitle,
+.processing-text,
+.mve-upload-meta {
+ color: rgba(17,18,20,0.62);
+ font-size: 0.82rem;
+}
+
+.mve-upload-actions,
+.mve-actions-row,
+.confirm-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.mve-upload-actions {
+ margin-top: 14px;
+}
+
+.mve-processing {
+ display: grid;
+ gap: 10px;
+ margin-top: 14px;
+}
+
+.progress-track {
+ width: 100%;
+ height: 8px;
+ border-radius: 999px;
+ background: rgba(17,18,20,0.08);
+ overflow: hidden;
+}
+
+.progress-indeterminate {
+ display: block;
+ width: 34%;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #030faa, #2b55e3);
+ animation: mve-progress 1.25s linear infinite;
+}
+
+@keyframes mve-progress {
+ from { transform: translateX(-120%); }
+ to { transform: translateX(320%); }
+}
+
+.mve-summary-grid {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.mve-summary-card {
+ background: #fff;
+ border: 1px solid rgba(17,18,20,0.07);
+ border-radius: 18px;
+ padding: 16px 14px;
+ display: grid;
+ gap: 6px;
+ box-shadow: 0 12px 24px rgba(17,18,20,0.05);
+
+ strong {
+ font-size: 1.36rem;
+ color: var(--text);
+ }
+
+ &.is-positive strong { color: #198754; }
+ &.is-danger strong { color: #b42318; }
+ &.is-warning strong { color: #b54708; }
+}
+
+.summary-label {
+ font-size: 0.76rem;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: rgba(17,18,20,0.58);
+ font-weight: 900;
+}
+
+.mve-result-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 14px;
+ align-items: center;
+}
+
+.mve-filter-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.mve-toolbar-right {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+}
+
+.mve-search-group {
+ min-width: min(420px, 100%);
+}
+
+.mve-sync-banner {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+ padding: 14px 16px;
+ border-radius: 16px;
+ background: rgba(12,132,78,0.08);
+ border: 1px solid rgba(12,132,78,0.16);
+ color: #0c6c43;
+ font-weight: 700;
+}
+
+.mve-empty {
+ display: grid;
+ place-items: center;
+ gap: 8px;
+ min-height: 180px;
+ border-radius: 18px;
+ border: 1px dashed rgba(17,18,20,0.14);
+ background: rgba(255,255,255,0.72);
+ color: rgba(17,18,20,0.62);
+
+ i {
+ font-size: 1.5rem;
+ color: #198754;
+ }
+}
+
+.mve-table-wrap {
+ max-height: min(48vh, 560px);
+ border-radius: 18px;
+ background: #fff;
+}
+
+.mve-table {
+ min-width: 980px;
+
+ thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: rgba(248,249,250,0.97);
+ font-size: 0.73rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: rgba(17,18,20,0.64);
+ font-weight: 900;
+ }
+}
+
+.mve-status-pair,
+.mve-cell-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.mve-status-pair {
+ .bi-arrow-right {
+ font-size: 0.8rem;
+ }
+}
+
+.mve-issue-tag,
+.mve-severity {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 0.74rem;
+ font-weight: 900;
+ border: 1px solid transparent;
+
+ &.status { background: rgba(180,35,24,0.09); color: #b42318; border-color: rgba(180,35,24,0.16); }
+ &.data { background: rgba(181,71,8,0.09); color: #b54708; border-color: rgba(181,71,8,0.16); }
+ &.system { background: rgba(3,15,170,0.08); color: var(--blue); border-color: rgba(3,15,170,0.15); }
+ &.report { background: rgba(10,91,168,0.08); color: #0a5ba8; border-color: rgba(10,91,168,0.15); }
+ &.duplicate { background: rgba(113,46,170,0.08); color: #712eaa; border-color: rgba(113,46,170,0.14); }
+ &.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.16); }
+ &.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.72); border-color: rgba(17,18,20,0.1); }
+}
+
+.mve-severity {
+ &.critical { background: rgba(180,35,24,0.08); color: #b42318; border-color: rgba(180,35,24,0.15); }
+ &.medium { background: rgba(181,71,8,0.08); color: #b54708; border-color: rgba(181,71,8,0.14); }
+ &.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.18); }
+ &.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.65); border-color: rgba(17,18,20,0.1); }
+}
+
+.mve-differences-text {
+ color: rgba(17,18,20,0.78);
+ line-height: 1.38;
+ min-width: 260px;
+}
+
+.mve-footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+}
+
+.mve-actions-row {
+ justify-content: flex-end;
+}
+
+.mve-confirm-overlay {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ justify-content: flex-end;
+ padding-top: 8px;
+}
+
+.mve-confirm-card {
+ width: min(420px, 100%);
+ background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(245,248,255,0.98));
+ border: 1px solid rgba(3,15,170,0.12);
+ border-radius: 18px;
+ padding: 18px;
+ box-shadow: 0 18px 36px rgba(17,18,20,0.12);
+ display: grid;
+ gap: 10px;
+}
+
+.confirm-title {
+ font-size: 1rem;
+ font-weight: 900;
+ color: var(--text);
+}
+
+.confirm-text,
+.confirm-footnote {
+ color: rgba(17,18,20,0.68);
+ line-height: 1.4;
+}
+
+.confirm-summary {
+ display: grid;
+ gap: 6px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: rgba(3,15,170,0.05);
+ border: 1px solid rgba(3,15,170,0.08);
+ font-weight: 700;
+}
+
+@media (max-width: 1200px) {
+ .mve-summary-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .modal-mve-audit {
+ width: calc(100vw - 20px);
+ max-width: calc(100vw - 20px);
+ max-height: calc(100vh - 20px);
+ }
+
+ .mve-intro-card,
+ .mve-result-toolbar,
+ .mve-footer,
+ .mve-sync-banner {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .mve-summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .mve-toolbar-right,
+ .mve-actions-row,
+ .confirm-actions {
+ width: 100%;
+ }
+
+ .mve-toolbar-right > *,
+ .mve-actions-row .btn,
+ .confirm-actions .btn,
+ .mve-upload-actions .btn {
+ flex: 1 1 100%;
+ }
+
+ .mve-search-group {
+ min-width: 100%;
+ }
+}
diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts
index 05f32d1..07f461d 100644
--- a/src/app/pages/geral/geral.spec.ts
+++ b/src/app/pages/geral/geral.spec.ts
@@ -73,4 +73,36 @@ describe('Geral', () => {
expect(component.createBatchLines[0].linha).toBe('11888888888');
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B');
});
+
+ it('should apply TIM filter in client-side pipeline using conta TIM textual', () => {
+ component.filterOperadora = 'TIM';
+ component.filterContaEmpresa = '';
+ component.filterStatus = 'ALL';
+ component.additionalMode = 'ALL';
+ component.selectedAdditionalServices = [];
+
+ const filtered = (component as any).applyAdditionalFiltersClientSide([
+ { id: '1', item: 1, conta: 'TIM', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ { id: '2', item: 2, conta: '455371844', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ ]);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].conta).toBe('TIM');
+ });
+
+ it('should combine operadora and empresa filters for VIVO MACROPHONY', () => {
+ component.filterOperadora = 'VIVO';
+ component.filterContaEmpresa = 'VIVO MACROPHONY';
+ component.filterStatus = 'ALL';
+ component.additionalMode = 'ALL';
+ component.selectedAdditionalServices = [];
+
+ const filtered = (component as any).applyAdditionalFiltersClientSide([
+ { id: '1', item: 1, conta: '460161507', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ { id: '2', item: 2, conta: '0435288088', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ ]);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].conta).toBe('460161507');
+ });
});
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts
index 7e78101..8f63072 100644
--- a/src/app/pages/geral/geral.ts
+++ b/src/app/pages/geral/geral.ts
@@ -17,14 +17,31 @@ 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 { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service';
+import { TableExportService } from '../../services/table-export.service';
+import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
+import {
+ MveAuditService,
+ type ApplyMveAuditResult,
+ type MveAuditIssue,
+ type MveAuditRun,
+} from '../../services/mve-audit.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+import { buildApiEndpoint } from '../../utils/api-base.util';
import {
BATCH_MASS_COLUMN_GUIDE,
type BatchMassApplyMode,
@@ -35,12 +52,23 @@ import {
buildBatchMassPreview,
mergeMassRows
} from './batch-mass-input.util';
+import {
+ DEFAULT_ACCOUNT_COMPANIES,
+ mergeAccountCompaniesWithDefaults,
+ normalizeConta as normalizeContaValue,
+ resolveEmpresaByConta,
+ resolveOperadoraContext,
+ sameConta as sameContaValue,
+} from '../../utils/account-operator.util';
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
type CreateEntryMode = 'SINGLE' | 'BATCH';
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;
@@ -68,6 +96,9 @@ interface ApiPagedResult {
interface ApiLineList {
id: string;
item: number;
+ conta?: string | null;
+ contaEmpresa?: string | null;
+ empresaConta?: string | null;
linha: string | null;
chip?: string | null;
cliente: string | null;
@@ -87,6 +118,14 @@ interface ApiLineList {
vivoGestaoDispositivo?: number | null;
}
+interface SmartSearchTargetResolution {
+ client: string;
+ skilFilter: SkilFilterMode;
+ statusFilter: 'ALL' | 'BLOCKED';
+ blockedStatusMode: BlockedStatusMode;
+ requiresFilterAdjustment: boolean;
+}
+
interface ApiLineDetail {
id: string;
item: number;
@@ -262,14 +301,67 @@ interface AssignReservaLinesResultDto {
items: AssignReservaLineItemResultDto[];
}
+type BatchStatusAction = 'BLOCK' | 'UNBLOCK';
+
+interface BatchLineStatusUpdateRequestDto {
+ action: 'block' | 'unblock';
+ blockStatus?: string | null;
+ applyToAllFiltered: boolean;
+ lineIds: string[];
+ search?: string | null;
+ skil?: string | null;
+ clients?: string[];
+ additionalMode?: string | null;
+ additionalServices?: string | null;
+ usuario?: string | null;
+}
+
+interface BatchLineStatusUpdateItemResultDto {
+ id: string;
+ item?: number;
+ linha?: string | null;
+ usuario?: string | null;
+ statusAnterior?: string | null;
+ statusNovo?: string | null;
+ success: boolean;
+ message: string;
+}
+
+interface BatchLineStatusUpdateResultDto {
+ requested: number;
+ updated: number;
+ failed: number;
+ items: BatchLineStatusUpdateItemResultDto[];
+}
+
+type MveAuditFilterMode =
+ | 'ALL'
+ | 'STATUS'
+ | 'DATA'
+ | 'ONLY_IN_SYSTEM'
+ | 'ONLY_IN_REPORT'
+ | 'DUPLICATES'
+ | 'INVALID'
+ | 'UNKNOWN';
+
+type MveAuditApplyMode = 'ALL_SYNCABLE' | 'FILTERED_SYNCABLE';
+
+interface MveApplySelectionSummary {
+ totalIssues: number;
+ totalStatusIssues: number;
+ totalDataIssues: number;
+ totalAffectedLines: number;
+}
+
@Component({
standalone: true,
- imports: [CommonModule, FormsModule, CustomSelectComponent],
+ imports: [CommonModule, FormsModule, CustomSelectComponent, GeralModalsComponent],
templateUrl: './geral.html',
styleUrls: ['./geral.scss']
})
export class Geral implements OnInit, AfterViewInit, OnDestroy {
+ readonly vm = this;
readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE;
toastMessage = '';
@@ -289,22 +381,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private planAutoFill: PlanAutoFillService,
private authService: AuthService,
private router: Router,
- private tenantSyncService: TenantSyncService
+ private route: ActivatedRoute,
+ private tenantSyncService: TenantSyncService,
+ private solicitacoesLinhasService: SolicitacoesLinhasService,
+ private tableExportService: TableExportService,
+ private mveAuditService: MveAuditService
) {}
- private readonly apiBase = (() => {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
- return `${apiBase}/lines`;
- })();
- private readonly templatesApiBase = (() => {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
- return `${apiBase}/templates`;
- })();
+ private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines');
+ private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates');
loading = false;
+ exporting = false;
isSysAdmin = false;
isGestor = false;
+ isFinanceiro = false;
isClientRestricted = false;
rows: LineRow[] = [];
@@ -314,9 +404,13 @@ 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';
selectedAdditionalServices: AdditionalServiceKey[] = [];
+ filterOperadora: OperadoraFilterMode = 'ALL';
+ filterContaEmpresa = '';
readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [
{ key: 'gvd', label: 'Gestão Voz e Dados' },
{ key: 'skeelo', label: 'Skeelo' },
@@ -325,6 +419,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
{ key: 'sync', label: 'Vivo Sync' },
{ key: 'dispositivo', label: 'Vivo Gestão Dispositivo' }
];
+ readonly operadoraFilterOptions: Array<{ label: string; value: OperadoraFilterMode }> = [
+ { label: 'Todas operadoras', value: 'ALL' },
+ { label: 'VIVO', value: 'VIVO' },
+ { label: 'CLARO', value: 'CLARO' },
+ { label: 'TIM', value: 'TIM' },
+ ];
clientsList: string[] = [];
loadingClientsList = false;
@@ -347,6 +447,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
financeOpen = false;
editOpen = false;
editSaving = false;
+ requestSaving = false;
createOpen = false;
createSaving = false;
@@ -378,6 +479,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
};
reservaTransferLastResult: AssignReservaLinesResultDto | null = null;
moveToReservaLastResult: AssignReservaLinesResultDto | null = null;
+ batchStatusOpen = false;
+ batchStatusSaving = false;
+ batchStatusAction: BatchStatusAction = 'BLOCK';
+ batchStatusType = '';
+ batchStatusUsuario = '';
+ batchStatusLastResult: BatchLineStatusUpdateResultDto | null = null;
+ mveAuditOpen = false;
+ mveAuditProcessing = false;
+ mveAuditApplying = false;
+ mveAuditFile: File | null = null;
+ mveAuditResult: MveAuditRun | null = null;
+ mveAuditError = '';
+ mveAuditFilter: MveAuditFilterMode = 'ALL';
+ mveAuditSearchTerm = '';
+ mveAuditPage = 1;
+ mveAuditPageSize = 10;
+ mveAuditPageSizeOptions = [10, 25, 50, 100];
+ mveAuditApplyConfirmOpen = false;
+ mveAuditApplyMode: MveAuditApplyMode = 'ALL_SYNCABLE';
+ mveAuditApplyLastResult: ApplyMveAuditResult | null = null;
detailData: any = null;
financeData: any = null;
@@ -391,6 +512,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private keepPageOnNextGroupsLoad = false;
private searchResolvedClient: string | null = null;
+ private searchRequestVersion = 0;
private kpiRequestVersion = 0;
private groupsRequestVersion = 0;
private linesRequestVersion = 0;
@@ -420,12 +542,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
'M2M 50MB'
];
- private readonly fallbackAccountCompanies: AccountCompanyOption[] = [
- { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840'] },
- { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844'] },
- { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] },
- { empresa: 'TIM LINE MÓVEL', contas: ['0072046192'] }
- ];
+ private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({
+ empresa: group.empresa,
+ contas: [...group.contas],
+ }));
accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies];
loadingAccountCompanies = false;
@@ -437,6 +557,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return this.accountCompanies.map((x) => x.empresa);
}
+ get contaEmpresaFilterOptions(): Array<{ label: string; value: string }> {
+ const empresas = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
+ const merged = this.mergeOption(this.filterContaEmpresa, empresas);
+ return [
+ { label: 'Todas empresas', value: '' },
+ ...merged.map((empresa) => ({ label: empresa, value: empresa })),
+ ];
+ }
+
get contaOptionsForCreate(): string[] {
return this.getContasByEmpresa(this.createModel?.contaEmpresa);
}
@@ -594,21 +723,67 @@ 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 {
- return !this.isClientRestricted && !!(this.expandedGroup ?? '').trim();
+ return this.canManageLines && !!(this.expandedGroup ?? '').trim();
+ }
+
+ get canManageLines(): boolean {
+ return this.isSysAdmin || this.isGestor;
}
get canMoveSelectedLinesToReserva(): boolean {
return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva;
}
+ get blockedStatusOptions(): string[] {
+ return this.statusOptions.filter((status) => !this.isActiveStatus(status));
+ }
+
+ get batchStatusSelectionCount(): number {
+ return this.reservaSelectedCount;
+ }
+
+ get canOpenBatchStatusModal(): boolean {
+ if (!this.canManageLines) return false;
+ if (this.loading || this.batchStatusSaving) return false;
+ return this.batchStatusSelectionCount > 0;
+ }
+
+ get canSubmitBatchStatusModal(): boolean {
+ if (this.batchStatusSaving) return false;
+ if (this.batchStatusSelectionCount <= 0) return false;
+ if (this.batchStatusAction === 'BLOCK' && !String(this.batchStatusType ?? '').trim()) return false;
+ return true;
+ }
+
+ get batchStatusActionLabel(): string {
+ return this.batchStatusAction === 'BLOCK' ? 'Bloquear' : 'Desbloquear';
+ }
+
+ get batchStatusTargetDescription(): string {
+ return `${this.batchStatusSelectionCount} linha(s) selecionada(s)`;
+ }
+
+ get batchStatusUserOptions(): string[] {
+ const users = (this.groupLines ?? [])
+ .map((x) => (x.usuario ?? '').toString().trim())
+ .filter((x) => !!x);
+
+ const current = (this.batchStatusUsuario ?? '').toString().trim();
+ if (current) users.push(current);
+
+ return Array.from(new Set(users)).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
+ }
+
get reservaSelectedCount(): number {
return this.reservaSelectedLineIds.length;
}
@@ -625,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' }));
}
@@ -644,6 +826,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0;
}
+ get hasOperadoraEmpresaFiltersApplied(): boolean {
+ return this.filterOperadora !== 'ALL' || !!this.filterContaEmpresa.trim();
+ }
+
+ get hasClientSideFiltersApplied(): boolean {
+ return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied;
+ }
+
get additionalModeLabel(): string {
if (this.additionalMode === 'WITH') return 'Com adicionais';
if (this.additionalMode === 'WITHOUT') return 'Sem adicionais';
@@ -655,6 +845,112 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
.map((key) => this.additionalServiceOptions.find((x) => x.key === key)?.label ?? key);
}
+ get hasMveAuditResult(): boolean {
+ return !!this.mveAuditResult;
+ }
+
+ get filteredMveAuditIssues(): MveAuditIssue[] {
+ const source = this.mveAuditResult?.issues ?? [];
+ const search = this.normalizeMveSearchTerm(this.mveAuditSearchTerm);
+
+ return source.filter((issue) => {
+ if (!this.matchesMveIssueFilter(issue)) {
+ return false;
+ }
+
+ if (!search) {
+ return true;
+ }
+
+ const haystack = [
+ issue.numeroLinha,
+ issue.issueType,
+ issue.situation,
+ issue.systemStatus,
+ issue.reportStatus,
+ issue.systemPlan,
+ issue.reportPlan,
+ issue.actionSuggestion,
+ issue.notes,
+ ...(issue.differences ?? []).flatMap((diff) => [diff.label, diff.systemValue, diff.reportValue]),
+ ]
+ .map((value) => this.normalizeMveSearchTerm(value))
+ .join(' ');
+
+ return haystack.includes(search);
+ });
+ }
+
+ get mveAuditTotalPages(): number {
+ return computeTotalPages(this.filteredMveAuditIssues.length, this.mveAuditPageSize);
+ }
+
+ get mveAuditPageNumbers(): number[] {
+ return buildPageNumbers(this.mveAuditPage, this.mveAuditTotalPages);
+ }
+
+ get pagedMveAuditIssues(): MveAuditIssue[] {
+ const start = computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
+ if (start <= 0) {
+ return this.filteredMveAuditIssues.slice(0, this.mveAuditPageSize);
+ }
+
+ const offset = start - 1;
+ return this.filteredMveAuditIssues.slice(offset, offset + this.mveAuditPageSize);
+ }
+
+ get mveAuditPageStart(): number {
+ return computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
+ }
+
+ get mveAuditPageEnd(): number {
+ return computePageEnd(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize);
+ }
+
+ get allSyncableMveIssues(): MveAuditIssue[] {
+ return (this.mveAuditResult?.issues ?? []).filter((issue) => issue.syncable && !issue.applied);
+ }
+
+ get filteredSyncableMveIssues(): MveAuditIssue[] {
+ return this.filteredMveAuditIssues.filter((issue) => issue.syncable && !issue.applied);
+ }
+
+ get selectedMveApplyIssues(): MveAuditIssue[] {
+ return this.mveAuditApplyMode === 'FILTERED_SYNCABLE'
+ ? this.filteredSyncableMveIssues
+ : this.allSyncableMveIssues;
+ }
+
+ get mveApplySelectionSummary(): MveApplySelectionSummary {
+ const selected = this.selectedMveApplyIssues;
+ const affectedLines = new Set();
+ let totalStatusIssues = 0;
+ let totalDataIssues = 0;
+
+ for (const issue of selected) {
+ if (issue.mobileLineId) affectedLines.add(issue.mobileLineId);
+ else if (issue.numeroLinha) affectedLines.add(issue.numeroLinha);
+
+ if (this.issueHasStatusDifference(issue)) totalStatusIssues++;
+ if (this.issueHasDataDifference(issue)) totalDataIssues++;
+ }
+
+ return {
+ totalIssues: selected.length,
+ totalStatusIssues,
+ totalDataIssues,
+ totalAffectedLines: affectedLines.size,
+ };
+ }
+
+ get canSubmitMveAudit(): boolean {
+ return !!this.mveAuditFile && !this.mveAuditProcessing && !this.mveAuditApplying;
+ }
+
+ get canOpenMveApplyConfirm(): boolean {
+ return !this.mveAuditApplying && this.mveApplySelectionSummary.totalIssues > 0;
+ }
+
// ✅ fecha dropdown ao clicar fora
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
@@ -728,10 +1024,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return;
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor');
- this.isClientRestricted = !(this.isSysAdmin || this.isGestor);
+ this.isFinanceiro = this.authService.hasRole('financeiro');
+ this.isClientRestricted = !(this.isSysAdmin || this.isGestor || this.isFinanceiro);
if (this.isClientRestricted) {
this.filterSkil = 'ALL';
+ this.filterStatus = 'ALL';
+ this.blockedStatusMode = 'ALL';
this.additionalMode = 'ALL';
this.selectedAdditionalServices = [];
this.selectedClients = [];
@@ -743,6 +1042,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.initAnimations();
setTimeout(() => {
+ this.applyRouteFilters(this.route.snapshot.queryParams);
this.refreshData();
if (!this.isClientRestricted) {
this.loadClients();
@@ -763,9 +1063,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();
@@ -782,6 +1086,175 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}, 100);
}
+ private applyRouteFilters(query: Record): void {
+ const skil = this.parseQuerySkilFilter(query['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']);
+ 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.searchTerm = '';
+ this.searchResolvedClient = null;
+ this.page = 1;
+ }
+
+ 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;
+ }
+
+ 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 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();
@@ -801,20 +1274,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.http.get(`${this.apiBase}/account-companies`).subscribe({
next: (data) => {
const normalized = this.normalizeAccountCompanies(data);
- this.accountCompanies =
- normalized.length > 0 ? normalized : [...this.fallbackAccountCompanies];
+ const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies;
+ this.accountCompanies = mergeAccountCompaniesWithDefaults(source);
this.loadingAccountCompanies = false;
+ this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
+ this.syncContaEmpresaSelection(this.detailData);
+ this.syncContaEmpresaSelection(this.financeData);
this.cdr.detectChanges();
},
error: () => {
- this.accountCompanies = [...this.fallbackAccountCompanies];
+ this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies);
this.loadingAccountCompanies = false;
+ this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
+ this.syncContaEmpresaSelection(this.detailData);
+ this.syncContaEmpresaSelection(this.financeData);
}
});
}
@@ -823,7 +1302,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
// ============================================================
private anyModalOpen(): boolean {
- return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen);
+ return !!(
+ this.detailOpen ||
+ this.financeOpen ||
+ this.editOpen ||
+ this.createOpen ||
+ this.reservaTransferOpen ||
+ this.moveToReservaOpen ||
+ this.batchStatusOpen ||
+ this.mveAuditOpen
+ );
}
private cleanupModalArtifacts() {
@@ -851,6 +1339,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.createOpen = false;
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
+ this.batchStatusOpen = false;
+ this.mveAuditOpen = false;
+ this.mveAuditApplyConfirmOpen = false;
this.detailData = null;
this.financeData = null;
@@ -858,6 +1349,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.aparelhoReciboFile = null;
this.editSaving = false;
+ this.requestSaving = false;
this.createSaving = false;
this.editModel = null;
@@ -869,8 +1361,21 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.batchExcelTemplateDownloading = false;
this.reservaTransferSaving = false;
this.moveToReservaSaving = false;
+ this.batchStatusSaving = false;
this.reservaTransferLastResult = null;
this.moveToReservaLastResult = null;
+ this.batchStatusLastResult = null;
+ this.batchStatusUsuario = '';
+ this.mveAuditFile = null;
+ this.mveAuditProcessing = false;
+ this.mveAuditApplying = false;
+ this.mveAuditResult = null;
+ this.mveAuditError = '';
+ this.mveAuditFilter = 'ALL';
+ this.mveAuditSearchTerm = '';
+ this.mveAuditPage = 1;
+ this.mveAuditApplyMode = 'ALL_SYNCABLE';
+ this.mveAuditApplyLastResult = null;
// Limpa overlays/locks residuais
this.cleanupModalArtifacts();
@@ -891,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') {
+ if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED')) {
this.page = 1;
}
this.searchResolvedClient = null;
@@ -904,7 +1409,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const t = (term ?? '').trim();
if (!t) return false;
- const digits = t.replace(/\D/g, '');
+ const digits = this.normalizeDigits(t);
if (!digits) return false;
if (digits.length >= 17) return true; // ICCID
@@ -913,37 +1418,154 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return false;
}
- private resolveSearchToClient(term: string): Promise {
+ private normalizeDigits(value: unknown): string {
+ return String(value ?? '').replace(/\D/g, '');
+ }
+
+ private resolveSkilFilterFromLine(skil: unknown, client: unknown): SkilFilterMode {
+ if (this.isStockClientName(client)) return 'ESTOQUE';
+ const parsed = this.parseQuerySkilFilter(skil);
+ return parsed ?? 'ALL';
+ }
+
+ private findBestSpecificSearchMatch(items: ApiLineList[], term: string): ApiLineList | null {
+ if (!Array.isArray(items) || items.length === 0) return null;
+
+ const digits = this.normalizeDigits(term);
+ if (!digits) return null;
+
+ const isIccidSearch = digits.length >= 17;
+ const exactMatches = items.filter((item) => {
+ const lineDigits = this.normalizeDigits(item?.linha);
+ const chipDigits = this.normalizeDigits(item?.chip);
+ return lineDigits === digits || chipDigits === digits;
+ });
+ if (exactMatches.length > 0) return exactMatches[0];
+
+ const compatibleMatches = items.filter((item) => {
+ const lineDigits = this.normalizeDigits(item?.linha);
+ const chipDigits = this.normalizeDigits(item?.chip);
+
+ if (isIccidSearch) {
+ return !!chipDigits && (
+ chipDigits.endsWith(digits) ||
+ digits.endsWith(chipDigits) ||
+ chipDigits.includes(digits)
+ );
+ }
+
+ return !!lineDigits && (
+ lineDigits.endsWith(digits) ||
+ digits.endsWith(lineDigits) ||
+ lineDigits.includes(digits)
+ );
+ });
+
+ return compatibleMatches[0] ?? items[0] ?? null;
+ }
+
+ private async findSpecificSearchMatch(
+ term: string,
+ options?: {
+ ignoreCurrentFilters?: boolean;
+ skilFilter?: SkilFilterMode;
+ }
+ ): Promise {
const s = (term ?? '').trim();
- if (!s) return Promise.resolve(null);
+ if (!s) return null;
- const pageSize = this.hasAdditionalFiltersApplied ? '500' : '1';
- let params = new HttpParams().set('page', '1').set('pageSize', pageSize).set('search', s);
- params = this.applyBaseFilters(params);
+ let params = new HttpParams()
+ .set('page', '1')
+ .set('pageSize', options?.ignoreCurrentFilters ? '200' : '500')
+ .set('search', s);
- if (this.selectedClients.length > 0) {
+ if (!options?.ignoreCurrentFilters) {
+ params = this.applyBaseFilters(params);
this.selectedClients.forEach((c) => (params = params.append('client', c)));
+ } else if (options?.skilFilter === 'PF') {
+ 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');
}
- return new Promise((resolve) => {
- this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({
- next: (res) => {
- const source = this.hasAdditionalFiltersApplied
- ? this.applyAdditionalFiltersClientSide(res.items ?? [])
- : (res.items ?? []);
- const first = source[0];
- const client = (first?.cliente ?? '').trim();
- resolve(client || null);
- },
- error: () => resolve(null)
- });
+ try {
+ const response = await firstValueFrom(
+ this.http.get>(this.apiBase, { params: this.withNoCache(params) })
+ );
+
+ let items = response?.items ?? [];
+ if (!options?.ignoreCurrentFilters && this.hasClientSideFiltersApplied) {
+ items = this.applyAdditionalFiltersClientSide(items);
+ }
+
+ return this.findBestSpecificSearchMatch(items, s);
+ } catch {
+ return null;
+ }
+ }
+
+ private buildSmartSearchTarget(
+ line: ApiLineList,
+ requiresFilterAdjustment: boolean
+ ): SmartSearchTargetResolution | null {
+ if (!line) return null;
+
+ const skilFilter = this.resolveSkilFilterFromLine(line?.skil, line?.cliente);
+ const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL';
+ const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter);
+
+ return {
+ client,
+ skilFilter,
+ statusFilter: blockedStatusMode === 'ALL' ? 'ALL' : 'BLOCKED',
+ blockedStatusMode,
+ requiresFilterAdjustment
+ };
+ }
+
+ private async resolveSmartSearchTarget(term: string): Promise {
+ const currentContextMatch = await this.findSpecificSearchMatch(term);
+ if (currentContextMatch) {
+ return this.buildSmartSearchTarget(currentContextMatch, false);
+ }
+
+ const globalMatch = await this.findSpecificSearchMatch(term, { ignoreCurrentFilters: true });
+ if (globalMatch) {
+ return this.buildSmartSearchTarget(globalMatch, true);
+ }
+
+ const reservaMatch = await this.findSpecificSearchMatch(term, {
+ ignoreCurrentFilters: true,
+ skilFilter: 'RESERVA'
});
+ if (reservaMatch) {
+ return this.buildSmartSearchTarget(reservaMatch, true);
+ }
+
+ return null;
+ }
+
+ private applySmartSearchFilters(target: SmartSearchTargetResolution): void {
+ this.filterSkil = target.skilFilter;
+ this.filterStatus = target.statusFilter;
+ this.blockedStatusMode = target.statusFilter === 'BLOCKED' ? target.blockedStatusMode : 'ALL';
+
+ this.selectedClients = [];
+ this.clientSearchTerm = '';
+ this.additionalMode = 'ALL';
+ this.selectedAdditionalServices = [];
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(async () => {
+ const requestVersion = ++this.searchRequestVersion;
+
this.expandedGroup = null;
this.groupLines = [];
this.page = 1;
@@ -951,6 +1573,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const term = (this.searchTerm ?? '').trim();
if (!term) {
+ if (requestVersion !== this.searchRequestVersion) return;
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
@@ -958,18 +1581,32 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
if (this.isSpecificSearchTerm(term)) {
- const client = await this.resolveSearchToClient(term);
+ const target = await this.resolveSmartSearchTarget(term);
- if (client) {
- this.searchResolvedClient = client;
+ if (requestVersion !== this.searchRequestVersion) return;
+
+ if (target) {
+ if (target.requiresFilterAdjustment) {
+ this.applySmartSearchFilters(target);
+ if (!this.isClientRestricted) {
+ this.loadClients();
+ }
+ }
+
+ this.searchResolvedClient = target.client;
this.loadKpis();
- await this.loadOnlyThisClientGroup(client);
- this.expandedGroup = client;
- this.fetchGroupLines(client, term);
+ await this.loadOnlyThisClientGroup(target.client);
+
+ if (requestVersion !== this.searchRequestVersion) return;
+
+ this.expandedGroup = target.client;
+ this.fetchGroupLines(target.client, term);
return;
}
}
+ if (requestVersion !== this.searchRequestVersion) return;
+
this.searchResolvedClient = null;
this.loadKpis();
this.loadGroups();
@@ -980,7 +1617,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);
}
@@ -1047,7 +1684,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.loadingClientsList = true;
this.clientsList = [];
- if (this.hasAdditionalFiltersApplied) {
+ if (this.hasClientSideFiltersApplied) {
void this.loadClientsFromLines(requestVersion);
return;
}
@@ -1104,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);
@@ -1120,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;
@@ -1143,6 +1780,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;
@@ -1189,12 +1867,47 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
+ setOperadoraFilter(mode: OperadoraFilterMode) {
+ if (this.isClientRestricted) return;
+ this.filterOperadora = mode;
+ this.syncContaEmpresaFilterByOperadora();
+ this.expandedGroup = null;
+ this.groupLines = [];
+ this.searchResolvedClient = null;
+ this.page = 1;
+
+ this.loadClients();
+ this.refreshData();
+ }
+
+ setContaEmpresaFilter(empresa: string) {
+ if (this.isClientRestricted) return;
+ const next = (empresa ?? '').toString().trim();
+ this.filterContaEmpresa = next;
+ this.expandedGroup = null;
+ this.groupLines = [];
+ this.searchResolvedClient = null;
+ this.page = 1;
+
+ this.loadClients();
+ this.refreshData();
+ }
+
private applyBaseFilters(params: HttpParams): HttpParams {
let next = params;
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');
+ 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');
@@ -1232,7 +1945,45 @@ 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;
+ }
+
+ if (!this.matchesOperadoraContaEmpresaFilters(line)) {
+ return false;
+ }
+
const selected = this.selectedAdditionalServices;
const hasSelected = selected.length > 0;
@@ -1256,6 +2007,40 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return true;
}
+ private matchesOperadoraContaEmpresaFilters(line: ApiLineList): boolean {
+ const hasOperadora = this.filterOperadora !== 'ALL';
+ const selectedEmpresa = this.filterContaEmpresa.trim();
+ const hasEmpresa = !!selectedEmpresa;
+ if (!hasOperadora && !hasEmpresa) return true;
+
+ const conta = (line as any)?.conta ?? (line as any)?.Conta ?? '';
+ const empresaConta = (line as any)?.contaEmpresa
+ ?? (line as any)?.empresaConta
+ ?? (line as any)?.ContaEmpresa
+ ?? (line as any)?.EmpresaConta
+ ?? (line as any)?.empresa_conta
+ ?? (line as any)?.Empresa_Conta
+ ?? (line as any)?.['empresa (conta)']
+ ?? (line as any)?.['EMPRESA (CONTA)']
+ ?? '';
+
+ const context = resolveOperadoraContext({
+ conta,
+ empresaConta,
+ accountCompanies: this.accountCompanies,
+ });
+
+ if (hasOperadora && context.operadora !== this.filterOperadora) {
+ return false;
+ }
+
+ if (!hasEmpresa) return true;
+
+ const resolvedEmpresa = (context.empresaConta || this.findEmpresaByConta(conta) || '').toString().trim();
+ if (!resolvedEmpresa) return false;
+ return this.normalizeFilterToken(resolvedEmpresa) === this.normalizeFilterToken(selectedEmpresa);
+ }
+
private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] {
if (!Array.isArray(lines) || lines.length === 0) return [];
return lines.filter((line) => this.matchesAdditionalFilters(line));
@@ -1292,7 +2077,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);
@@ -1407,11 +2192,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.isReserveContextFilter() || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) {
this.page = 1;
}
- if (this.hasAdditionalFiltersApplied) {
+ if (this.hasClientSideFiltersApplied) {
void this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion);
return;
}
@@ -1548,7 +2333,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private buildGroupsFromLines(lines: ApiLineList[]): ClientGroupDto[] {
const grouped = new Map();
- 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;
@@ -1569,7 +2354,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;
}
}
@@ -1580,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;
@@ -1603,49 +2391,91 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.fetchGroupLines(clientName, useTerm);
}
+ private async fetchAllGroupLines(
+ clientName: string,
+ search: string | undefined,
+ requestVersion: number
+ ): Promise {
+ try {
+ let baseParams = new HttpParams()
+ .set('client', clientName)
+ .set('sortBy', 'item')
+ .set('sortDir', 'asc');
+
+ baseParams = this.applyBaseFilters(baseParams);
+
+ if (search) {
+ baseParams = baseParams.set('search', search);
+ }
+
+ const pageSize = 5000;
+ let page = 1;
+ let expectedTotal = 0;
+ const allItems: ApiLineList[] = [];
+
+ while (page <= 500) {
+ const params = baseParams
+ .set('page', String(page))
+ .set('pageSize', String(pageSize));
+
+ const response = await firstValueFrom(
+ this.http.get>(this.apiBase, {
+ params: this.withNoCache(params)
+ })
+ );
+
+ if (requestVersion !== this.linesRequestVersion) return;
+
+ const items = response?.items ?? [];
+ expectedTotal = this.toInt(response?.total);
+ allItems.push(...items);
+
+ if (items.length === 0) break;
+ if (items.length < pageSize) break;
+ if (expectedTotal > 0 && allItems.length >= expectedTotal) break;
+
+ page += 1;
+ }
+
+ if (requestVersion !== this.linesRequestVersion) return;
+
+ const filteredItems = this.applyAdditionalFiltersClientSide(allItems);
+ this.groupLines = filteredItems.map((x) => this.mapApiLineListToLineRow(x));
+
+ this.loadingLines = false;
+ this.cdr.detectChanges();
+ } catch {
+ if (requestVersion !== this.linesRequestVersion) return;
+ this.loadingLines = false;
+ await this.showToast('Erro ao carregar linhas do grupo.');
+ }
+ }
+
+ private mapApiLineListToLineRow(x: ApiLineList): LineRow {
+ return {
+ id: x.id,
+ item: String(x.item ?? ''),
+ linha: x.linha ?? '',
+ chip: x.chip ?? '',
+ cliente: x.cliente ?? '',
+ usuario: x.usuario ?? '',
+ centroDeCustos: x.centroDeCustos ?? '',
+ setorNome: x.setorNome ?? '',
+ aparelhoNome: x.aparelhoNome ?? '',
+ aparelhoCor: x.aparelhoCor ?? '',
+ status: x.status ?? '',
+ skil: x.skil ?? '',
+ contrato: x.vencConta ?? ''
+ };
+ }
+
fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion;
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
- let params = new HttpParams()
- .set('client', clientName)
- .set('page', '1')
- .set('pageSize', '500')
- .set('sortBy', 'item')
- .set('sortDir', 'asc');
- params = this.applyBaseFilters(params);
-
- if (search) params = params.set('search', search);
-
- this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({
- next: (res) => {
- if (requestVersion !== this.linesRequestVersion) return;
- const filteredItems = this.applyAdditionalFiltersClientSide(res.items ?? []);
- this.groupLines = filteredItems.map((x) => ({
- id: x.id,
- item: String(x.item ?? ''),
- linha: x.linha ?? '',
- chip: x.chip ?? '',
- cliente: x.cliente ?? '',
- usuario: x.usuario ?? '',
- centroDeCustos: x.centroDeCustos ?? '',
- setorNome: x.setorNome ?? '',
- aparelhoNome: x.aparelhoNome ?? '',
- aparelhoCor: x.aparelhoCor ?? '',
- status: x.status ?? '',
- skil: x.skil ?? '',
- contrato: x.vencConta ?? ''
- }));
- this.loadingLines = false;
- },
- error: () => {
- if (requestVersion !== this.linesRequestVersion) return;
- this.loadingLines = false;
- this.showToast('Erro ao carregar linhas do grupo.');
- }
- });
+ void this.fetchAllGroupLines(clientName, search, requestVersion);
}
toggleClientMenu() {
@@ -1739,7 +2569,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
goToPage(p: number) {
- this.page = Math.max(1, Math.min(this.totalPages, p));
+ this.page = clampPage(p, this.totalPages);
this.refreshData({ keepCurrentPage: true });
}
@@ -1754,7 +2584,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
get totalPages() {
if (this.selectedClients.length > 0) return 1;
if (this.searchResolvedClient) return 1;
- return Math.ceil((this.total || 0) / this.pageSize) || 1;
+ return computeTotalPages(this.total || 0, this.pageSize);
}
get filteredCount() {
@@ -1762,7 +2592,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get pageStart() {
- return this.filteredCount === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
+ return computePageStart(this.filteredCount, this.page, this.pageSize);
}
get pageEnd() {
@@ -1780,15 +2610,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
get pageNumbers() {
- 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;
+ return buildPageNumbers(this.page, this.totalPages);
}
clearSearch() {
@@ -1800,6 +2622,250 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const baseRows = await this.getRowsForExport();
+ const rows = await this.getDetailedRowsForExport(baseRows);
+ if (!rows.length) {
+ await this.showToast('Nenhum registro encontrado para exportar.');
+ return;
+ }
+
+ const suffix = this.getExportFilterSuffix();
+ const timestamp = this.tableExportService.buildTimestamp();
+ const fileName = `geral_${suffix}_${timestamp}`;
+ const templateBuffer = await this.getGeralTemplateBuffer();
+
+ await this.tableExportService.exportAsXlsx({
+ fileName,
+ sheetName: 'Geral',
+ templateBuffer,
+ rows,
+ columns: [
+ { header: 'ID', value: (row) => row.id },
+ { header: 'Item', type: 'number', value: (row) => this.toInt(row.item) },
+ { header: 'Empresa (Conta)', value: (row) => this.findEmpresaByConta(row.conta) },
+ { header: 'Conta', value: (row) => row.conta ?? '' },
+ { header: 'Linha', value: (row) => row.linha ?? '' },
+ { header: 'Chip', value: (row) => row.chip ?? '' },
+ { header: 'Tipo de Chip', value: (row) => row.tipoDeChip ?? '' },
+ { header: 'Cliente', value: (row) => row.cliente ?? '' },
+ { header: 'Usuario', value: (row) => row.usuario ?? '' },
+ { header: 'Centro de Custos', value: (row) => row.centroDeCustos ?? '' },
+ { header: 'Setor ID', value: (row) => row.setorId ?? '' },
+ { header: 'Setor', value: (row) => row.setorNome ?? '' },
+ { header: 'Aparelho ID', value: (row) => row.aparelhoId ?? '' },
+ { header: 'Aparelho', value: (row) => row.aparelhoNome ?? '' },
+ { header: 'Cor do Aparelho', value: (row) => row.aparelhoCor ?? '' },
+ { header: 'IMEI do Aparelho', value: (row) => row.aparelhoImei ?? '' },
+ { header: 'NF do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoNotaFiscalTemArquivo },
+ { header: 'Recibo do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoReciboTemArquivo },
+ { header: 'Plano Contrato', value: (row) => row.planoContrato ?? '' },
+ { header: 'Status', value: (row) => row.status ?? '' },
+ { header: 'Tipo (Skil)', value: (row) => row.skil ?? '' },
+ { header: 'Modalidade', value: (row) => row.modalidade ?? '' },
+ { header: 'Cedente', value: (row) => row.cedente ?? '' },
+ { header: 'Solicitante', value: (row) => row.solicitante ?? '' },
+ { header: 'Data de Bloqueio', type: 'date', value: (row) => row.dataBloqueio ?? '' },
+ { header: 'Data Entrega Operadora', type: 'date', value: (row) => row.dataEntregaOpera ?? '' },
+ { header: 'Data Entrega Cliente', type: 'date', value: (row) => row.dataEntregaCliente ?? '' },
+ { header: 'Dt. Efetivacao Servico', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' },
+ { header: 'Dt. Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' },
+ { header: 'Vencimento da Conta', value: (row) => row.vencConta ?? '' },
+ { header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 },
+ { header: 'Valor Plano Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorPlanoVivo) ?? 0 },
+ { header: 'Gestao Voz e Dados', type: 'currency', value: (row) => this.toNullableNumber(row.gestaoVozDados) ?? 0 },
+ { header: 'Skeelo', type: 'currency', value: (row) => this.toNullableNumber(row.skeelo) ?? 0 },
+ { header: 'Vivo News Plus', type: 'currency', value: (row) => this.toNullableNumber(row.vivoNewsPlus) ?? 0 },
+ { header: 'Vivo Travel Mundo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoTravelMundo) ?? 0 },
+ { header: 'Vivo Gestao Dispositivo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoGestaoDispositivo) ?? 0 },
+ { header: 'Vivo Sync', type: 'currency', value: (row) => this.toNullableNumber(row.vivoSync) ?? 0 },
+ { header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 },
+ { header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 },
+ { header: 'Franquia Gestao', type: 'number', value: (row) => this.toNullableNumber(row.franquiaGestao) ?? 0 },
+ { header: 'Locacao AP', type: 'currency', value: (row) => this.toNullableNumber(row.locacaoAp) ?? 0 },
+ { header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 },
+ { header: 'Desconto', type: 'currency', value: (row) => this.toNullableNumber(row.desconto) ?? 0 },
+ { header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 },
+ { header: 'Criado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['createdAt', 'CreatedAt']) ?? '' },
+ { header: 'Atualizado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['updatedAt', 'UpdatedAt']) ?? '' },
+ ],
+ });
+
+ await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
+ } catch {
+ await this.showToast('Erro ao exportar a planilha.');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private async getRowsForExport(): Promise {
+ let lines = await this.fetchLinesForGrouping();
+
+ if (this.selectedClients.length > 0) {
+ const selected = new Set(
+ this.selectedClients.map((client) => (client ?? '').toString().trim().toUpperCase())
+ );
+ lines = lines.filter((line) => selected.has((line.cliente ?? '').toString().trim().toUpperCase()));
+ }
+
+ const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE');
+ const mapped = lines.map((line) => ({
+ id: (line.id ?? '').toString(),
+ item: String(line.item ?? ''),
+ linha: line.linha ?? '',
+ chip: line.chip ?? '',
+ cliente: ((line.cliente ?? '').toString().trim()) || fallbackClient,
+ usuario: line.usuario ?? '',
+ centroDeCustos: line.centroDeCustos ?? '',
+ setorNome: line.setorNome ?? '',
+ aparelhoNome: line.aparelhoNome ?? '',
+ aparelhoCor: line.aparelhoCor ?? '',
+ status: line.status ?? '',
+ skil: line.skil ?? '',
+ contrato: line.vencConta ?? '',
+ }));
+
+ return mapped.sort((a, b) => {
+ const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
+ if (byClient !== 0) return byClient;
+
+ const byItem = this.toInt(a.item) - this.toInt(b.item);
+ if (byItem !== 0) return byItem;
+
+ return (a.linha ?? '').localeCompare(b.linha ?? '', 'pt-BR', { sensitivity: 'base' });
+ });
+ }
+
+ private async getDetailedRowsForExport(baseRows: LineRow[]): Promise {
+ if (!baseRows.length) return [];
+
+ const result: ApiLineDetail[] = [];
+ const chunkSize = 8;
+
+ for (let i = 0; i < baseRows.length; i += chunkSize) {
+ const chunk = baseRows.slice(i, i + chunkSize);
+ const fetched = await Promise.all(
+ chunk.map(async (row) => {
+ try {
+ return await firstValueFrom(
+ this.http.get(`${this.apiBase}/${row.id}`, {
+ params: this.withNoCache(new HttpParams()),
+ })
+ );
+ } catch {
+ return this.toDetailFallback(row);
+ }
+ })
+ );
+
+ result.push(...fetched);
+ }
+
+ return result;
+ }
+
+ private toDetailFallback(row: LineRow): ApiLineDetail {
+ return {
+ id: row.id,
+ item: this.toInt(row.item),
+ qtdLinhas: null,
+ conta: row.contrato ?? null,
+ linha: row.linha ?? null,
+ chip: row.chip ?? null,
+ tipoDeChip: null,
+ cliente: row.cliente ?? null,
+ usuario: row.usuario ?? null,
+ centroDeCustos: row.centroDeCustos ?? null,
+ setorId: null,
+ setorNome: row.setorNome ?? null,
+ aparelhoId: null,
+ aparelhoNome: row.aparelhoNome ?? null,
+ aparelhoCor: row.aparelhoCor ?? null,
+ aparelhoImei: null,
+ aparelhoNotaFiscalTemArquivo: false,
+ aparelhoReciboTemArquivo: false,
+ planoContrato: null,
+ status: row.status ?? null,
+ skil: row.skil ?? null,
+ modalidade: null,
+ dataBloqueio: null,
+ cedente: null,
+ solicitante: null,
+ dataEntregaOpera: null,
+ dataEntregaCliente: null,
+ dtEfetivacaoServico: null,
+ dtTerminoFidelizacao: null,
+ vencConta: row.contrato ?? null,
+ franquiaVivo: null,
+ valorPlanoVivo: null,
+ gestaoVozDados: null,
+ skeelo: null,
+ vivoNewsPlus: null,
+ vivoTravelMundo: null,
+ vivoGestaoDispositivo: null,
+ vivoSync: null,
+ valorContratoVivo: null,
+ franquiaLine: null,
+ franquiaGestao: null,
+ locacaoAp: null,
+ valorContratoLine: null,
+ desconto: null,
+ lucro: null,
+ };
+ }
+
+ private async getGeralTemplateBuffer(): Promise {
+ try {
+ const params = new HttpParams().set('_', `${Date.now()}`);
+ const blob = await firstValueFrom(
+ this.http.get(`${this.templatesApiBase}/planilha-geral`, {
+ params,
+ responseType: 'blob',
+ })
+ );
+ return await blob.arrayBuffer();
+ } catch {
+ return null;
+ }
+ }
+
+ private getExportFilterSuffix(): string {
+ const parts: string[] = [];
+
+ if (this.filterSkil === 'PF') parts.push('pf');
+ else if (this.filterSkil === 'PJ') parts.push('pj');
+ else if (this.filterSkil === 'RESERVA') parts.push('reservas');
+ else if (this.filterSkil === 'ESTOQUE') parts.push('estoque');
+ else parts.push('todas');
+
+ if (this.filterStatus === 'BLOCKED') {
+ if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo');
+ else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120');
+ else parts.push('bloqueadas');
+ }
+
+ if (this.additionalMode === 'WITH') parts.push('com-adicionais');
+ else if (this.additionalMode === 'WITHOUT') parts.push('sem-adicionais');
+
+ if (this.selectedAdditionalServices.length > 0) {
+ parts.push(this.selectedAdditionalServices.join('-'));
+ }
+
+ if (this.filterOperadora !== 'ALL') {
+ parts.push(`operadora-${this.filterOperadora.toLowerCase()}`);
+ }
+ if (this.filterContaEmpresa.trim()) {
+ parts.push(`empresa-${this.normalizeFilterToken(this.filterContaEmpresa).toLowerCase()}`);
+ }
+
+ return parts.join('_');
+ }
+
async onImportExcel() {
if (!this.isSysAdmin) {
await this.showToast('Você não tem permissão para importar planilha.');
@@ -1835,6 +2901,236 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
});
}
+ async openMveAuditModal() {
+ if (!this.canManageLines) {
+ await this.showToast('Você não tem permissão para auditar linhas com o relatório MVE.');
+ return;
+ }
+
+ this.mveAuditOpen = true;
+ this.mveAuditFile = null;
+ this.mveAuditResult = null;
+ this.mveAuditError = '';
+ this.mveAuditFilter = 'ALL';
+ this.mveAuditSearchTerm = '';
+ this.mveAuditPage = 1;
+ this.mveAuditApplyMode = 'ALL_SYNCABLE';
+ this.mveAuditApplyConfirmOpen = false;
+ this.mveAuditApplyLastResult = null;
+ this.cdr.detectChanges();
+ }
+
+ onMveAuditFileSelected(event: Event) {
+ const file = (event.target as HTMLInputElement | null)?.files?.[0] ?? null;
+ this.mveAuditError = '';
+ this.mveAuditApplyLastResult = null;
+
+ if (!file) {
+ this.mveAuditFile = null;
+ return;
+ }
+
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ this.mveAuditFile = null;
+ this.mveAuditError = 'Selecione um arquivo CSV exportado do MVE da Vivo.';
+ return;
+ }
+
+ if (file.size <= 0) {
+ this.mveAuditFile = null;
+ this.mveAuditError = 'O arquivo selecionado está vazio.';
+ return;
+ }
+
+ if (file.size > 20 * 1024 * 1024) {
+ this.mveAuditFile = null;
+ this.mveAuditError = 'O arquivo do MVE excede o limite de 20 MB.';
+ return;
+ }
+
+ this.mveAuditFile = file;
+ }
+
+ clearMveAuditFile() {
+ this.mveAuditFile = null;
+ this.mveAuditError = '';
+ }
+
+ async submitMveAudit() {
+ if (!this.canSubmitMveAudit || !this.mveAuditFile) {
+ return;
+ }
+
+ this.mveAuditProcessing = true;
+ this.mveAuditError = '';
+ this.mveAuditResult = null;
+ this.mveAuditApplyLastResult = null;
+
+ try {
+ this.mveAuditResult = await firstValueFrom(this.mveAuditService.preview(this.mveAuditFile));
+ this.mveAuditFilter = 'ALL';
+ this.mveAuditSearchTerm = '';
+ this.mveAuditPage = 1;
+ await this.showToast('Auditoria MVE processada com sucesso.');
+ } catch (err) {
+ this.mveAuditError = this.extractHttpMessage(err, 'Não foi possível processar o relatório MVE.');
+ } finally {
+ this.mveAuditProcessing = false;
+ this.cdr.detectChanges();
+ }
+ }
+
+ setMveAuditFilter(filter: MveAuditFilterMode) {
+ this.mveAuditFilter = filter;
+ this.mveAuditPage = 1;
+ }
+
+ onMveAuditSearchChange() {
+ this.mveAuditPage = 1;
+ }
+
+ onMveAuditPageSizeChange() {
+ this.mveAuditPage = 1;
+ }
+
+ goToMveAuditPage(page: number) {
+ this.mveAuditPage = clampPage(page, this.mveAuditTotalPages);
+ }
+
+ openMveApplyConfirm(mode: MveAuditApplyMode) {
+ if (!this.hasMveAuditResult) return;
+ this.mveAuditApplyMode = mode;
+ if (!this.canOpenMveApplyConfirm) return;
+ this.mveAuditApplyConfirmOpen = true;
+ }
+
+ closeMveApplyConfirm() {
+ this.mveAuditApplyConfirmOpen = false;
+ }
+
+ async confirmMveApply() {
+ if (!this.mveAuditResult || !this.canOpenMveApplyConfirm) {
+ return;
+ }
+
+ const selectedIssues = this.selectedMveApplyIssues;
+ this.mveAuditApplying = true;
+
+ try {
+ const result = await firstValueFrom(
+ this.mveAuditService.apply(
+ this.mveAuditResult.id,
+ this.mveAuditApplyMode === 'FILTERED_SYNCABLE' ? selectedIssues.map((issue) => issue.id) : undefined
+ )
+ );
+
+ this.mveAuditApplyLastResult = result;
+ this.mveAuditResult = await firstValueFrom(this.mveAuditService.getById(this.mveAuditResult.id));
+ this.mveAuditApplyConfirmOpen = false;
+
+ const label =
+ result.updatedLines > 0
+ ? `${result.updatedLines} linha(s) atualizada(s) com base no MVE.`
+ : 'Nenhuma linha precisou ser alterada com a sincronização MVE.';
+ await this.showToast(label);
+ this.refreshData({ keepCurrentPage: true });
+ } catch (err) {
+ await this.showToast(this.extractHttpMessage(err, 'Não foi possível aplicar a sincronização MVE.'));
+ } finally {
+ this.mveAuditApplying = false;
+ this.cdr.detectChanges();
+ }
+ }
+
+ async exportMveAuditIssues() {
+ if (!this.hasMveAuditResult || this.filteredMveAuditIssues.length === 0) {
+ await this.showToast('Não há inconsistências filtradas para exportar.');
+ return;
+ }
+
+ const headers = [
+ 'Numero da linha',
+ 'Item sistema',
+ 'Situacao',
+ 'Tipo',
+ 'Status sistema',
+ 'Status relatorio',
+ 'Plano sistema',
+ 'Plano relatorio',
+ 'Diferencas',
+ 'Acao sugerida',
+ 'Observacoes',
+ ];
+
+ const rows = this.filteredMveAuditIssues.map((issue) =>
+ [
+ issue.numeroLinha || '',
+ issue.systemItem != null ? String(issue.systemItem) : '',
+ issue.situation || '',
+ issue.issueType || '',
+ issue.systemStatus || '',
+ issue.reportStatus || '',
+ issue.systemPlan || '',
+ issue.reportPlan || '',
+ this.describeMveDifferences(issue),
+ issue.actionSuggestion || '',
+ issue.notes || '',
+ ]
+ .map((value) => this.escapeCsvValue(value))
+ .join(';')
+ );
+
+ const content = `${headers.join(';')}\n${rows.join('\n')}`;
+ const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
+ const timestamp = this.tableExportService.buildTimestamp();
+ this.downloadBlob(blob, `mve_auditoria_${timestamp}.csv`);
+ await this.showToast(`CSV exportado com ${rows.length} inconsistência(s).`);
+ }
+
+ describeMveDifferences(issue: MveAuditIssue): string {
+ const differences = issue.differences ?? [];
+ if (!differences.length) {
+ return issue.notes ?? '-';
+ }
+
+ return differences
+ .map((diff) => `${diff.label}: ${diff.systemValue ?? '-'} -> ${diff.reportValue ?? '-'}`)
+ .join(' | ');
+ }
+
+ getMveIssueTagClass(issue: MveAuditIssue): string {
+ switch (issue.issueType) {
+ 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':
+ return 'system';
+ case 'ONLY_IN_REPORT':
+ return 'report';
+ case 'DUPLICATE_REPORT':
+ case 'DUPLICATE_SYSTEM':
+ return 'duplicate';
+ case 'INVALID_ROW':
+ case 'DDD_CHANGE_REVIEW':
+ case 'UNKNOWN_STATUS':
+ return 'warning';
+ default:
+ return 'neutral';
+ }
+ }
+
+ getMveSeverityClass(severity: string | null | undefined): string {
+ const normalized = (severity ?? '').toString().trim().toUpperCase();
+ if (normalized === 'HIGH') return 'critical';
+ if (normalized === 'MEDIUM') return 'medium';
+ if (normalized === 'WARNING') return 'warning';
+ return 'neutral';
+ }
+
private getById(id: string, cb: (d: any) => void) {
this.http.get(`${this.apiBase}/${id}`).subscribe({
next: cb,
@@ -1847,7 +3143,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.detailData = null;
this.cdr.detectChanges();
this.getById(r.id, (d) => {
- this.detailData = d;
+ this.detailData = {
+ ...d,
+ contaEmpresa: this.findEmpresaByConta(d?.conta)
+ };
+ this.syncContaEmpresaSelection(this.detailData);
this.cdr.detectChanges();
});
}
@@ -1863,8 +3163,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onEditar(r: LineRow) {
+ if (this.isFinanceiro) {
+ await this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.editOpen = true;
this.editSaving = false;
+ this.requestSaving = false;
this.editModel = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
@@ -1942,11 +3248,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async saveEdit() {
- if (!this.editingId || !this.editModel) return;
+ if (this.isFinanceiro) {
+ await this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ 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 +3268,28 @@ 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 {
+ const contaEmpresaValidationMessage = this.validateContaEmpresaBinding(this.editModel);
+ if (contaEmpresaValidationMessage) {
+ this.editSaving = false;
+ await this.showToast(contaEmpresaValidationMessage);
+ return;
+ }
+
this.calculateFinancials(this.editModel);
- const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel;
+ const {
+ contaEmpresa: _contaEmpresa,
+ franquiaLineSolicitada: _franquiaLineSolicitada,
+ ...editModelPayload
+ } = this.editModel;
payload = {
...editModelPayload,
@@ -2004,12 +3331,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 +3392,31 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
});
}
+ async requestLineBlock() {
+ if (!this.isClientRestricted) {
+ await this.showToast('Somente cliente pode solicitar bloqueio por essa ação.');
+ return;
+ }
+
+ 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;
@@ -2172,7 +3560,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onCadastrarLinha() {
- if (this.isClientRestricted) {
+ if (!this.canManageLines) {
await this.showToast('Você não tem permissão para cadastrar novos clientes.');
return;
}
@@ -2185,7 +3573,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
async onAddLineToGroup(clientName: string) {
- if (this.isClientRestricted) {
+ if (!this.canManageLines) {
await this.showToast('Você não tem permissão para adicionar linhas.');
return;
}
@@ -2193,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);
@@ -2981,6 +4369,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!hasMatch) model.conta = '';
}
+ onContaChange(isEdit: boolean) {
+ const model = isEdit ? this.editModel : this.createModel;
+ this.syncContaEmpresaSelection(model);
+ }
+
+ onBatchContaChange(row: any) {
+ this.syncContaEmpresaSelection(row);
+ this.onBatchLineDetailsChange();
+ }
+
onDocTypeChange() {
this.createModel.docNumber = '';
this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA';
@@ -3167,6 +4565,116 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.reservaSelectedLineIds = [];
}
+ async openBatchStatusModal(action: BatchStatusAction) {
+ if (!this.canManageLines) {
+ await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.');
+ return;
+ }
+
+ if (this.batchStatusSelectionCount <= 0) {
+ await this.showToast('Selecione ao menos uma linha para processar.');
+ return;
+ }
+
+ this.batchStatusAction = action;
+ this.batchStatusSaving = false;
+ this.batchStatusLastResult = null;
+ this.batchStatusUsuario = '';
+
+ if (action === 'BLOCK') {
+ const current = (this.batchStatusType ?? '').toString().trim();
+ const options = this.blockedStatusOptions;
+ if (!current || !options.some((x) => x === current)) {
+ this.batchStatusType = options[0] ?? '';
+ }
+ } else {
+ this.batchStatusType = '';
+ }
+
+ this.batchStatusOpen = true;
+ this.cdr.detectChanges();
+ }
+
+ async submitBatchStatusUpdate() {
+ if (this.batchStatusSaving) return;
+ if (!this.canSubmitBatchStatusModal) return;
+
+ const payload = this.buildBatchStatusPayload();
+ this.batchStatusSaving = true;
+
+ this.http.post(`${this.apiBase}/batch-status-update`, payload).subscribe({
+ next: async (res) => {
+ this.batchStatusSaving = false;
+ this.batchStatusLastResult = res;
+
+ const ok = Number(res?.updated ?? 0) || 0;
+ const failed = Number(res?.failed ?? 0) || 0;
+
+ this.batchStatusOpen = false;
+ this.clearReservaSelection();
+ this.batchStatusUsuario = '';
+
+ await this.showToast(
+ failed > 0
+ ? `${this.batchStatusActionLabel} em lote concluído com pendências: ${ok} linha(s) processada(s), ${failed} falha(s).`
+ : `${this.batchStatusActionLabel} em lote concluído: ${ok} linha(s) processada(s).`
+ );
+
+ if (this.expandedGroup) {
+ const term = (this.searchTerm ?? '').trim();
+ const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
+ this.fetchGroupLines(this.expandedGroup, useTerm);
+ }
+
+ this.loadGroups();
+ this.loadKpis();
+ },
+ error: async (err: HttpErrorResponse) => {
+ this.batchStatusSaving = false;
+ const msg = (err.error as any)?.message || 'Erro ao processar bloqueio/desbloqueio em lote.';
+ await this.showToast(msg);
+ }
+ });
+ }
+
+ private buildBatchStatusPayload(): BatchLineStatusUpdateRequestDto {
+ const clients = this.searchResolvedClient
+ ? [this.searchResolvedClient]
+ : [...this.selectedClients];
+
+ const normalizedClients = clients
+ .map((x) => (x ?? '').toString().trim())
+ .filter((x) => !!x);
+
+ const userFilter = (this.batchStatusUsuario ?? '').toString().trim();
+
+ return {
+ action: this.batchStatusAction === 'BLOCK' ? 'block' : 'unblock',
+ blockStatus: this.batchStatusAction === 'BLOCK' ? (this.batchStatusType || null) : null,
+ applyToAllFiltered: false,
+ lineIds: [...this.reservaSelectedLineIds],
+ search: (this.searchTerm ?? '').toString().trim() || null,
+ skil: this.resolveFilterSkilForApi(),
+ clients: normalizedClients,
+ additionalMode: this.resolveAdditionalModeForApi(),
+ additionalServices: this.selectedAdditionalServices.length > 0 ? this.selectedAdditionalServices.join(',') : null,
+ usuario: userFilter || null
+ };
+ }
+
+ private resolveFilterSkilForApi(): string | null {
+ if (this.filterSkil === 'PF') return 'PESSOA FÍSICA';
+ if (this.filterSkil === 'PJ') return 'PESSOA JURÍDICA';
+ if (this.isReserveContextFilter()) return 'RESERVA';
+ return null;
+ }
+
+ private resolveAdditionalModeForApi(): string | null {
+ if (this.additionalMode === 'WITH') return 'with';
+ if (this.additionalMode === 'WITHOUT') return 'without';
+ return null;
+ }
+
async openReservaTransferModal() {
if (!this.isReservaExpandedGroup) {
await this.showToast('Abra um grupo no filtro Reserva para selecionar e atribuir linhas.');
@@ -3233,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;
}
@@ -3555,6 +5066,73 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}
}
+ private matchesMveIssueFilter(issue: MveAuditIssue): boolean {
+ switch (this.mveAuditFilter) {
+ case 'STATUS':
+ return this.issueHasStatusDifference(issue);
+ case 'DATA':
+ return this.issueHasDataDifference(issue);
+ case 'ONLY_IN_SYSTEM':
+ return issue.issueType === 'ONLY_IN_SYSTEM';
+ case 'ONLY_IN_REPORT':
+ return issue.issueType === 'ONLY_IN_REPORT';
+ case 'DUPLICATES':
+ return issue.issueType === 'DUPLICATE_REPORT' || issue.issueType === 'DUPLICATE_SYSTEM';
+ case 'INVALID':
+ return issue.issueType === 'INVALID_ROW';
+ case 'UNKNOWN':
+ return issue.issueType === 'UNKNOWN_STATUS';
+ default:
+ return true;
+ }
+ }
+
+ private issueHasStatusDifference(issue: MveAuditIssue): boolean {
+ return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status');
+ }
+
+ private issueHasDataDifference(issue: MveAuditIssue): boolean {
+ return (issue.differences ?? []).some(
+ (difference) => difference.fieldKey !== 'status' && difference.syncable
+ );
+ }
+
+ private normalizeMveSearchTerm(value: unknown): string {
+ return (value ?? '')
+ .toString()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .trim();
+ }
+
+ private escapeCsvValue(value: unknown): string {
+ const text = (value ?? '').toString().replace(/"/g, '""');
+ return `"${text}"`;
+ }
+
+ private downloadBlob(blob: Blob, fileName: string) {
+ if (!isPlatformBrowser(this.platformId)) return;
+
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ private extractHttpMessage(error: unknown, fallbackMessage: string): string {
+ const httpError = error as HttpErrorResponse | null;
+ return (
+ (httpError?.error as { message?: string } | null)?.message ||
+ httpError?.message ||
+ fallbackMessage
+ );
+ }
+
formatMoney(v: any): string {
if (v == null || Number.isNaN(v)) return '-';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v);
@@ -3588,6 +5166,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return v || '-';
}
+ private isActiveStatus(status: string | null | undefined): boolean {
+ const normalized = (status ?? '').toString().trim().toLowerCase();
+ if (!normalized) return false;
+ return normalized.includes('ativo');
+ }
+
private toEditModel(d: ApiLineDetail): any {
return {
...d,
@@ -3618,6 +5202,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 +5245,21 @@ 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 getAnyField(row: unknown, keys: string[]): unknown {
+ const source = row as Record;
+ for (const key of keys) {
+ if (source && source[key] !== undefined && source[key] !== null && source[key] !== '') {
+ return source[key];
+ }
+ }
+ return null;
+ }
+
private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim();
if (!v) return list;
@@ -3711,34 +5311,91 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return found ? [...found.contas] : [];
}
- private findEmpresaByConta(conta: any): string {
- const target = this.normalizeConta(conta);
- if (!target) return '';
+ private getContaEmpresaOptionsByOperadora(mode: OperadoraFilterMode): string[] {
+ const empresas = this.mergeOptionList([], this.accountCompanies.map((group) => group?.empresa ?? ''))
+ .filter((empresa) => !!(empresa ?? '').toString().trim());
- const found = this.accountCompanies.find((group) =>
- (group.contas ?? []).some((c) => this.sameConta(c, target))
- );
- return found?.empresa ?? '';
+ const filtered = mode === 'ALL'
+ ? empresas
+ : empresas.filter((empresa) => {
+ const operadora = resolveOperadoraContext({
+ empresaConta: empresa,
+ accountCompanies: this.accountCompanies,
+ }).operadora;
+ return operadora === mode;
+ });
+
+ return filtered.sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
+ }
+
+ private syncContaEmpresaFilterByOperadora(): void {
+ const selected = this.filterContaEmpresa.trim();
+ if (!selected) return;
+
+ const available = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
+ const normalizedSelected = this.normalizeFilterToken(selected);
+ const hasSelected = available.some((empresa) => this.normalizeFilterToken(empresa) === normalizedSelected);
+
+ if (!hasSelected) {
+ this.filterContaEmpresa = '';
+ }
+ }
+
+ private findEmpresaByConta(conta: any): string {
+ return resolveEmpresaByConta(conta, this.accountCompanies);
}
private normalizeConta(value: any): string {
- const raw = (value ?? '').toString().trim();
- if (!raw) return '';
- if (!/^\d+$/.test(raw)) return raw.toUpperCase();
- const noLeadingZero = raw.replace(/^0+/, '');
- return noLeadingZero || '0';
+ return normalizeContaValue(value);
}
private sameConta(a: any, b: any): boolean {
- return this.normalizeConta(a) === this.normalizeConta(b);
+ return sameContaValue(a, b);
}
private syncContaEmpresaSelection(model: any) {
if (!model) return;
+ const contaAtual = (model.conta ?? '').toString().trim();
const empresaAtual = (model.contaEmpresa ?? '').toString().trim();
- if (empresaAtual) return;
+ if (!contaAtual) {
+ if (!empresaAtual) {
+ model.contaEmpresa = '';
+ }
+ return;
+ }
- model.contaEmpresa = this.findEmpresaByConta(model.conta);
+ const empresaPorConta = this.findEmpresaByConta(contaAtual);
+ if (empresaPorConta) {
+ model.contaEmpresa = empresaPorConta;
+ return;
+ }
+
+ if (!empresaAtual) {
+ model.contaEmpresa = '';
+ }
+ }
+
+ private validateContaEmpresaBinding(model: any): string | null {
+ if (!model) return 'Dados da linha inválidos.';
+
+ this.syncContaEmpresaSelection(model);
+
+ const conta = (model.conta ?? '').toString().trim();
+ const contaEmpresa = (model.contaEmpresa ?? '').toString().trim();
+
+ if (!contaEmpresa) return 'Selecione a Empresa (Conta).';
+ if (!conta) return 'Selecione uma Conta.';
+
+ const empresaPorConta = this.findEmpresaByConta(conta);
+ if (!empresaPorConta) {
+ return 'A conta informada não está vinculada a nenhuma Empresa (Conta) cadastrada.';
+ }
+
+ if (empresaPorConta.localeCompare(contaEmpresa, 'pt-BR', { sensitivity: 'base' }) !== 0) {
+ model.contaEmpresa = empresaPorConta;
+ }
+
+ return null;
}
}
diff --git a/src/app/pages/historico-chips/historico-chips.html b/src/app/pages/historico-chips/historico-chips.html
new file mode 100644
index 0000000..be22f7c
--- /dev/null
+++ b/src/app/pages/historico-chips/historico-chips.html
@@ -0,0 +1,274 @@
+
+
+
+
+ {{ toastMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMsg || 'Erro ao carregar histórico do chip.' }}
+ Tentar novamente
+
+
+
+ Nenhuma alteração encontrada para os filtros informados.
+
+
+
0">
+
+
+ Data/Hora
+ Usuário
+ Origem
+ Ação
+ Resumo da alteração
+ Detalhes
+
+
+
+
+
+ {{ formatDateTime(log.occurredAtUtc) }}
+
+
+ {{ displayUserName(log) }}
+ {{ log.userEmail || '-' }}
+
+
+
+ {{ log.page || '-' }}
+
+
+ {{ formatAction(log.action) }}
+
+
+
+ {{ summary.title }}
+ {{ summary.description }}
+
+ {{ formatChangeValue(summary.before) }}
+
+ {{ formatChangeValue(summary.after) }}
+
+
+ DDD: {{ formatChangeValue(summary.beforeDdd) }} {{ formatChangeValue(summary.afterDdd) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mudanças de campos
+
+
+
+
+
+ {{ change.field }}
+
+ {{ changeTypeLabel(change.changeType) }}
+
+
+
+ {{ formatChangeValue(change.oldValue) }}
+
+ {{ formatChangeValue(change.newValue) }}
+
+
+
+
+
+ Sem mudanças detalhadas nesse evento.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/historico-chips/historico-chips.ts b/src/app/pages/historico-chips/historico-chips.ts
new file mode 100644
index 0000000..b7a637b
--- /dev/null
+++ b/src/app/pages/historico-chips/historico-chips.ts
@@ -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();
+ private readonly idFieldExceptions = new Set(['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 {
+ 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({
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/src/app/pages/historico-linhas/historico-linhas.html b/src/app/pages/historico-linhas/historico-linhas.html
new file mode 100644
index 0000000..a000b4a
--- /dev/null
+++ b/src/app/pages/historico-linhas/historico-linhas.html
@@ -0,0 +1,274 @@
+
+
+
+
+ {{ toastMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMsg || 'Erro ao carregar histórico da linha.' }}
+ Tentar novamente
+
+
+
+ Nenhuma alteração encontrada para os filtros informados.
+
+
+
0">
+
+
+ Data/Hora
+ Usuário
+ Origem
+ Ação
+ Resumo da alteração
+ Detalhes
+
+
+
+
+
+ {{ formatDateTime(log.occurredAtUtc) }}
+
+
+ {{ displayUserName(log) }}
+ {{ log.userEmail || '-' }}
+
+
+
+ {{ log.page || '-' }}
+
+
+ {{ formatAction(log.action) }}
+
+
+
+ {{ summary.title }}
+ {{ summary.description }}
+
+ {{ formatChangeValue(summary.before) }}
+
+ {{ formatChangeValue(summary.after) }}
+
+
+ DDD: {{ formatChangeValue(summary.beforeDdd) }} {{ formatChangeValue(summary.afterDdd) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mudanças de campos
+
+
+
+
+
+ {{ change.field }}
+
+ {{ changeTypeLabel(change.changeType) }}
+
+
+
+ {{ formatChangeValue(change.oldValue) }}
+
+ {{ formatChangeValue(change.newValue) }}
+
+
+
+
+
+ Sem mudanças detalhadas nesse evento.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/historico-linhas/historico-linhas.scss b/src/app/pages/historico-linhas/historico-linhas.scss
new file mode 100644
index 0000000..1fe3352
--- /dev/null
+++ b/src/app/pages/historico-linhas/historico-linhas.scss
@@ -0,0 +1,648 @@
+:host {
+ --brand: #e33dcf;
+ --brand-soft: rgba(227, 61, 207, 0.12);
+ --blue: #030faa;
+ --text: #111214;
+ --muted: rgba(17, 18, 20, 0.64);
+ --surface: rgba(255, 255, 255, 0.9);
+ --surface-strong: #ffffff;
+ --line: rgba(15, 23, 42, 0.11);
+ --radius-xl: 22px;
+ --radius-lg: 16px;
+ --shadow-card: 0 20px 44px rgba(17, 18, 20, 0.1);
+
+ display: block;
+ font-family: 'Inter', sans-serif;
+ color: var(--text);
+ box-sizing: border-box;
+}
+
+.historico-linhas-page {
+ min-height: 100vh;
+ padding: 0 12px;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ position: relative;
+ overflow-y: auto;
+ background:
+ radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
+ radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
+ linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: rgba(255, 255, 255, 0.25);
+ }
+}
+
+.page-blob {
+ position: fixed;
+ pointer-events: none;
+ border-radius: 999px;
+ filter: blur(34px);
+ opacity: 0.55;
+ z-index: 0;
+ background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.55), rgba(227, 61, 207, 0.06));
+ animation: floaty 10s ease-in-out infinite;
+
+ &.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; }
+ &.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; }
+ &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; }
+ &.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: 0.45; }
+}
+
+@keyframes floaty {
+ 0% { transform: translate(0, 0) scale(1); }
+ 50% { transform: translate(18px, 10px) scale(1.03); }
+ 100% { transform: translate(0, 0) scale(1); }
+}
+
+.container-geral-responsive {
+ width: 98% !important;
+ max-width: 1500px !important;
+ position: relative;
+ z-index: 1;
+ margin-top: 40px;
+ margin-bottom: 200px;
+}
+
+.geral-card {
+ border-radius: var(--radius-xl);
+ overflow: hidden;
+ background: var(--surface);
+ border: 1px solid rgba(227, 61, 207, 0.16);
+ backdrop-filter: blur(12px);
+ box-shadow: var(--shadow-card);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 80vh;
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 1px;
+ border-radius: calc(var(--radius-xl) - 1px);
+ pointer-events: none;
+ border: 1px solid rgba(255, 255, 255, 0.65);
+ opacity: 0.75;
+ }
+}
+
+.geral-header {
+ padding: 16px 24px;
+ border-bottom: 1px solid rgba(17, 18, 20, 0.06);
+ background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2));
+ flex-shrink: 0;
+}
+
+.header-row-top {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ gap: 12px;
+
+ @media (max-width: 900px) {
+ grid-template-columns: 1fr;
+ text-align: center;
+ gap: 14px;
+
+ .title-badge { justify-self: center; }
+ .header-actions { justify-self: center; }
+ }
+}
+
+.title-badge {
+ justify-self: start;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid rgba(227, 61, 207, 0.22);
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 800;
+
+ i { color: var(--brand); }
+}
+
+.header-title {
+ justify-self: center;
+ text-align: center;
+}
+
+.title {
+ font-size: 26px;
+ font-weight: 950;
+ letter-spacing: -0.3px;
+ color: var(--text);
+ margin-top: 10px;
+}
+
+.subtitle {
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.header-actions {
+ justify-self: end;
+}
+
+.btn-brand {
+ background-color: var(--brand);
+ border-color: var(--brand);
+ color: #fff;
+ font-weight: 900;
+ border-radius: 12px;
+ transition: transform 0.2s, box-shadow 0.2s;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
+ filter: brightness(1.05);
+ }
+}
+
+.btn-glass {
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid rgba(17, 18, 20, 0.16);
+ color: rgba(17, 18, 20, 0.85);
+ border-radius: 12px;
+ font-weight: 700;
+}
+
+.filters-card {
+ background: rgba(255, 255, 255, 0.92);
+ border: 1px solid rgba(17, 18, 20, 0.08);
+ border-radius: 16px;
+ padding: 16px;
+ display: grid;
+ gap: 14px;
+ box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08);
+}
+
+.filters-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.filters-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 900;
+ font-size: 14px;
+ color: rgba(17, 18, 20, 0.82);
+}
+
+.filters-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.filters-grid {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.filter-field {
+ display: grid;
+ gap: 6px;
+ grid-column: span 2;
+ min-width: 0;
+
+ label {
+ font-size: 11px;
+ font-weight: 800;
+ color: rgba(17, 18, 20, 0.6);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ input {
+ width: 100%;
+ height: 40px;
+ border-radius: 10px;
+ border: 1px solid rgba(15, 23, 42, 0.15);
+ padding: 0 12px;
+ font-size: 14px;
+ background: #fff;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+ }
+
+ input:focus {
+ outline: none;
+ border-color: var(--brand);
+ box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12);
+ }
+}
+
+.line-field {
+ grid-column: span 4;
+}
+
+.period-field {
+ grid-column: span 3;
+}
+
+.btn-primary,
+.btn-ghost {
+ height: 38px;
+ border-radius: 10px;
+ border: none;
+ font-weight: 700;
+ font-size: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 14px;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--brand), #bc30ac);
+ color: #fff;
+ box-shadow: 0 8px 16px rgba(227, 61, 207, 0.24);
+}
+
+.btn-ghost {
+ background: rgba(15, 23, 42, 0.06);
+ color: rgba(15, 23, 42, 0.85);
+}
+
+.kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.kpi-card {
+ background: var(--surface-strong);
+ border: 1px solid rgba(17, 18, 20, 0.08);
+ border-radius: 14px;
+ padding: 12px 14px;
+ display: grid;
+ gap: 6px;
+ box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06);
+}
+
+.kpi-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: rgba(17, 18, 20, 0.62);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.kpi-value {
+ font-size: 22px;
+ line-height: 1;
+ font-weight: 900;
+ color: var(--blue);
+}
+
+.geral-body {
+ padding: 18px 24px;
+ flex: 1;
+}
+
+.table-wrap {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.84);
+ border: 1px solid rgba(15, 23, 42, 0.1);
+ border-radius: 14px;
+ overflow: hidden;
+}
+
+.table-modern {
+ margin: 0;
+ min-width: 980px;
+
+ thead th {
+ background: linear-gradient(180deg, rgba(3, 15, 170, 0.92), rgba(3, 15, 170, 0.82));
+ color: #fff;
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: none;
+ padding: 12px;
+ white-space: nowrap;
+ }
+
+ tbody td {
+ border-top: 1px solid rgba(15, 23, 42, 0.08);
+ vertical-align: middle;
+ padding: 12px;
+ background: rgba(255, 255, 255, 0.92);
+ }
+
+ tbody tr.table-row-item:hover td {
+ background: rgba(227, 61, 207, 0.05);
+ }
+
+ tbody tr.table-row-item.expanded td {
+ background: rgba(227, 61, 207, 0.08);
+ }
+}
+
+.user-cell {
+ display: grid;
+ line-height: 1.2;
+}
+
+.user-name {
+ font-weight: 800;
+}
+
+.user-email {
+ color: rgba(17, 18, 20, 0.55);
+}
+
+.origin-pill {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 4px 10px;
+ background: rgba(3, 15, 170, 0.1);
+ border: 1px solid rgba(3, 15, 170, 0.2);
+ color: rgba(3, 15, 170, 0.88);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.badge-action {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 5px 10px;
+ font-size: 12px;
+ font-weight: 800;
+ border: 1px solid transparent;
+
+ &.action-create {
+ color: #157347;
+ background: rgba(25, 135, 84, 0.12);
+ border-color: rgba(25, 135, 84, 0.24);
+ }
+
+ &.action-update {
+ color: #0a58ca;
+ background: rgba(13, 110, 253, 0.12);
+ border-color: rgba(13, 110, 253, 0.24);
+ }
+
+ &.action-delete {
+ color: #b02a37;
+ background: rgba(220, 53, 69, 0.12);
+ border-color: rgba(220, 53, 69, 0.24);
+ }
+
+ &.action-default {
+ color: #495057;
+ background: rgba(108, 117, 125, 0.12);
+ border-color: rgba(108, 117, 125, 0.24);
+ }
+}
+
+.summary-col {
+ min-width: 360px;
+}
+
+.summary-title {
+ font-size: 13px;
+ font-weight: 900;
+ margin-bottom: 2px;
+}
+
+.summary-description {
+ font-size: 12px;
+ color: rgba(17, 18, 20, 0.66);
+}
+
+.summary-diff {
+ margin-top: 6px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ border-radius: 10px;
+ background: rgba(15, 23, 42, 0.05);
+ padding: 4px 8px;
+
+ .old {
+ color: #b02a37;
+ font-weight: 700;
+ }
+
+ .new {
+ color: #157347;
+ font-weight: 700;
+ }
+}
+
+.summary-ddd {
+ margin-top: 5px;
+ font-size: 11px;
+ color: rgba(17, 18, 20, 0.62);
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.tone-mureg { color: #005f73; }
+.tone-troca { color: #6f42c1; }
+.tone-status { color: #0a58ca; }
+.tone-linha { color: #0d6efd; }
+.tone-chip { color: #198754; }
+.tone-generic { color: #495057; }
+
+.actions-col {
+ width: 84px;
+ text-align: center;
+}
+
+.expand-btn {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ border: 1px solid rgba(15, 23, 42, 0.15);
+ background: #fff;
+ color: rgba(15, 23, 42, 0.85);
+}
+
+.details-row td {
+ background: rgba(255, 255, 255, 0.94);
+}
+
+.details-panel {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 12px;
+}
+
+.details-section {
+ border: 1px solid rgba(15, 23, 42, 0.1);
+ border-radius: 12px;
+ background: #fff;
+ padding: 10px 12px;
+}
+
+.section-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-weight: 800;
+ color: rgba(17, 18, 20, 0.84);
+ margin-bottom: 8px;
+}
+
+.changes-list {
+ display: grid;
+ gap: 8px;
+}
+
+.change-item {
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 10px;
+ padding: 8px;
+ background: rgba(248, 249, 250, 0.85);
+}
+
+.change-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.change-field {
+ font-size: 12px;
+ font-weight: 800;
+ color: #111;
+}
+
+.change-type {
+ border-radius: 999px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 800;
+ border: 1px solid transparent;
+
+ &.change-added {
+ background: rgba(25, 135, 84, 0.12);
+ color: #157347;
+ border-color: rgba(25, 135, 84, 0.24);
+ }
+
+ &.change-removed {
+ background: rgba(220, 53, 69, 0.12);
+ color: #b02a37;
+ border-color: rgba(220, 53, 69, 0.24);
+ }
+
+ &.change-modified {
+ background: rgba(13, 110, 253, 0.12);
+ color: #0a58ca;
+ border-color: rgba(13, 110, 253, 0.24);
+ }
+}
+
+.change-values {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+
+ .old {
+ color: #b02a37;
+ font-weight: 700;
+ }
+
+ .new {
+ color: #157347;
+ font-weight: 700;
+ }
+}
+
+.empty-state {
+ font-size: 13px;
+ color: rgba(17, 18, 20, 0.62);
+ font-weight: 600;
+}
+
+.empty-group {
+ text-align: center;
+ padding: 28px;
+ color: rgba(17, 18, 20, 0.65);
+ font-weight: 700;
+}
+
+.empty-group.helper {
+ background: rgba(3, 15, 170, 0.05);
+ border-bottom: 1px solid rgba(3, 15, 170, 0.12);
+}
+
+.geral-footer {
+ display: none;
+}
+
+.footer-meta {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+
+.pagination-modern .page-link {
+ border-radius: 10px;
+ border: 1px solid rgba(15, 23, 42, 0.15);
+ color: rgba(15, 23, 42, 0.85);
+ margin: 0 2px;
+}
+
+.pagination-modern .page-item.active .page-link {
+ background: var(--brand);
+ border-color: var(--brand);
+ color: #fff;
+}
+
+@media (max-width: 1200px) {
+ .filters-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
+ .filter-field { grid-column: span 2; }
+ .line-field { grid-column: span 3; }
+ .period-field { grid-column: span 3; }
+ .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .details-panel { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 768px) {
+ .geral-header,
+ .geral-body,
+ .geral-footer {
+ padding-left: 14px;
+ padding-right: 14px;
+ }
+
+ .filters-grid { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+ .filter-field,
+ .line-field {
+ grid-column: span 1;
+ }
+
+ .kpi-grid {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
+}
diff --git a/src/app/pages/historico-linhas/historico-linhas.ts b/src/app/pages/historico-linhas/historico-linhas.ts
new file mode 100644
index 0000000..22c5307
--- /dev/null
+++ b/src/app/pages/historico-linhas/historico-linhas.ts
@@ -0,0 +1,573 @@
+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,
+ LineHistoricoQuery
+} 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-linhas',
+ standalone: true,
+ imports: [CommonModule, FormsModule, CustomSelectComponent],
+ templateUrl: './historico-linhas.html',
+ styleUrls: ['./historico-linhas.scss'],
+})
+export class HistoricoLinhas 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;
+
+ filterLine = '';
+ filterPageName = '';
+ filterAction = '';
+ filterUser = '';
+ dateFrom = '';
+ dateTo = '';
+
+ readonly pageOptions: SelectOption[] = [
+ { value: '', label: 'Todas as origens' },
+ { value: 'Geral', label: 'Geral' },
+ { value: 'Mureg', label: 'Mureg' },
+ { value: 'Troca de número', label: 'Troca de número' },
+ { value: 'Vigência', label: 'Vigência' },
+ { value: 'Parcelamentos', label: 'Parcelamentos' },
+ ];
+
+ 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();
+ private readonly idFieldExceptions = new Set(['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.filterLine = '';
+ 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 {
+ 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({
+ fileName: `historico_linhas_${timestamp}`,
+ sheetName: 'HistoricoLinhas',
+ 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: 'DDD Anterior', value: (log) => this.summaryFor(log).beforeDdd ?? '' },
+ { header: 'DDD Novo', value: (log) => this.summaryFor(log).afterDdd ?? '' },
+ { 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 linhas.');
+ } 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 normalizedLineTerm(): string {
+ return (this.filterLine || '').trim();
+ }
+
+ get hasLineFilter(): boolean {
+ return !!this.normalizedLineTerm;
+ }
+
+ 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 statusCountInPage(): number {
+ return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length;
+ }
+
+ get trocaCountInPage(): number {
+ return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length;
+ }
+
+ get muregCountInPage(): number {
+ return this.logs.filter((log) => this.summaryFor(log).tone === 'mureg').length;
+ }
+
+ private fetch(): void {
+ this.loading = true;
+ this.error = false;
+ this.errorMsg = '';
+ this.expandedLogId = null;
+
+ const query: LineHistoricoQuery = {
+ ...this.buildBaseQuery(),
+ line: this.normalizedLineTerm || undefined,
+ page: this.page,
+ pageSize: this.pageSize,
+ };
+
+ this.historicoService.listByLine(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 da linha. Tente novamente.';
+ }
+ });
+ }
+
+ private async fetchAllLogsForExport(): Promise {
+ const pageSize = 500;
+ let page = 1;
+ let expectedTotal = 0;
+ const all: AuditLogDto[] = [];
+
+ while (page <= 500) {
+ const response = await firstValueFrom(
+ this.historicoService.listByLine({
+ ...this.buildBaseQuery(),
+ line: this.normalizedLineTerm || 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 {
+ 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');
+ const linhaAntiga = this.findChange(log, 'linhaantiga');
+ const linhaNova = this.findChange(log, 'linhanova');
+
+ const muregLike = entity === 'muregline' || page.includes('mureg');
+ if (muregLike) {
+ const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue);
+ const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue);
+ return {
+ title: 'Troca de Mureg',
+ description: 'Linha alterada no fluxo de Mureg.',
+ before,
+ after,
+ beforeDdd: this.extractDdd(before),
+ afterDdd: this.extractDdd(after),
+ tone: 'mureg',
+ };
+ }
+
+ 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 (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',
+ };
+ }
+
+ 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',
+ };
+ }
+
+ 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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/src/app/pages/historico/historico.html b/src/app/pages/historico/historico.html
index e01f1e8..9cc9940 100644
--- a/src/app/pages/historico/historico.html
+++ b/src/app/pages/historico/historico.html
@@ -33,6 +33,10 @@
Atualizar
+
+ Exportar
+ Exportando...
+
diff --git a/src/app/pages/historico/historico.ts b/src/app/pages/historico/historico.ts
index 5ce35cf..6dbd52b 100644
--- a/src/app/pages/historico/historico.ts
+++ b/src/app/pages/historico/historico.ts
@@ -2,9 +2,18 @@ import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PL
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, HistoricoQuery } 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;
@@ -23,6 +32,7 @@ export class Historico implements OnInit {
logs: AuditLogDto[] = [];
loading = false;
+ exporting = false;
error = false;
errorMsg = '';
toastMessage = '';
@@ -65,7 +75,8 @@ export class Historico implements OnInit {
constructor(
private historicoService: HistoricoService,
private cdr: ChangeDetectorRef,
- @Inject(PLATFORM_ID) private platformId: object
+ @Inject(PLATFORM_ID) private platformId: object,
+ private tableExportService: TableExportService
) {}
ngOnInit(): void {
@@ -111,35 +122,66 @@ export class Historico implements OnInit {
this.fetch();
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const logs = await this.fetchAllLogsForExport();
+ if (!logs.length) {
+ await this.showToast('Nenhum registro encontrado para exportar.');
+ return;
+ }
+
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `historico_${timestamp}`,
+ sheetName: 'Historico',
+ rows: logs,
+ columns: [
+ { header: 'ID', value: (log) => log.id ?? '' },
+ { header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' },
+ { header: 'Usuario', value: (log) => this.displayUserName(log) },
+ { header: 'E-mail', value: (log) => log.userEmail ?? '' },
+ { header: 'Pagina', value: (log) => log.page ?? '' },
+ { header: 'Acao', value: (log) => this.formatAction(log.action) },
+ { header: 'Entidade', value: (log) => this.displayEntity(log) },
+ { header: 'Id Entidade', value: (log) => log.entityId ?? '' },
+ { header: 'Metodo HTTP', value: (log) => log.requestMethod ?? '' },
+ { header: 'Endpoint', value: (log) => log.requestPath ?? '' },
+ { header: 'IP', value: (log) => log.ipAddress ?? '' },
+ { header: 'Mudancas', value: (log) => this.formatChangesSummary(log) },
+ { header: 'Qtd Mudancas', type: 'number', value: (log) => log.changes?.length ?? 0 },
+ ],
+ });
+
+ await this.showToast(`Planilha exportada com ${logs.length} registro(s).`);
+ } catch {
+ await this.showToast('Erro ao exportar planilha.');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
goToPage(p: number): void {
- this.page = Math.max(1, Math.min(this.totalPages, p));
+ this.page = clampPage(p, this.totalPages);
this.fetch();
}
get totalPages(): number {
- return Math.ceil((this.total || 0) / this.pageSize) || 1;
+ return computeTotalPages(this.total || 0, this.pageSize);
}
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;
+ return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
- return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
+ return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
- if (this.total === 0) return 0;
- return Math.min(this.page * this.pageSize, this.total);
+ return computePageEnd(this.total || 0, this.page, this.pageSize);
}
toggleDetails(log: AuditLogDto, event?: Event): void {
@@ -217,14 +259,9 @@ export class Historico implements OnInit {
this.expandedLogId = null;
const query: HistoricoQuery = {
+ ...this.buildBaseQuery(),
page: this.page,
pageSize: this.pageSize,
- pageName: this.filterPageName || undefined,
- action: this.filterAction || undefined,
- user: this.filterUser?.trim() || undefined,
- search: this.filterSearch?.trim() || undefined,
- dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
- dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
this.historicoService.list(query).subscribe({
@@ -247,6 +284,58 @@ export class Historico implements OnInit {
});
}
+ private async fetchAllLogsForExport(): Promise {
+ const pageSize = 500;
+ let page = 1;
+ let expectedTotal = 0;
+ const all: AuditLogDto[] = [];
+
+ while (page <= 500) {
+ const response = await firstValueFrom(
+ this.historicoService.list({
+ ...this.buildBaseQuery(),
+ 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 {
+ return {
+ pageName: this.filterPageName || undefined,
+ action: this.filterAction || undefined,
+ user: this.filterUser?.trim() || undefined,
+ search: this.filterSearch?.trim() || undefined,
+ dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
+ dateTo: this.toIsoDate(this.dateTo, true) || undefined,
+ };
+ }
+
+ private formatChangesSummary(log: AuditLogDto): string {
+ const changes = log?.changes ?? [];
+ 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 toIsoDate(value: string, endOfDay: boolean): string | null {
if (!value) return null;
const time = endOfDay ? '23:59:59' : '00:00:00';
diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html
index 58c0764..ba4d93b 100644
--- a/src/app/pages/mureg/mureg.html
+++ b/src/app/pages/mureg/mureg.html
@@ -31,7 +31,11 @@
@@ -173,10 +177,10 @@
-
+
-
+
@@ -218,316 +222,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ editModel.clienteInfo }}
-
-
-
-
-
-
-
-
-
-
- Preparando edição...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ createModel.clienteInfo }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Carregando detalhes...
-
-
-
-
-
-
-
-
- Linha Nova
- {{ detailData.linhaNova || '-' }}
-
-
- Linha Antiga
- {{ detailData.linhaAntiga || '-' }}
-
-
- Cliente
- {{ detailData.cliente || '-' }}
-
-
- Usuário
- {{ detailData.usuario || '-' }}
-
-
- Item
- {{ detailData.item || '-' }}
-
-
- Data Mureg
- {{ displayValue('dataDaMureg', detailData.dataDaMureg) }}
-
-
- ICCID
- {{ detailData.iccid || '-' }}
-
-
- Skil
- {{ detailData.skil || '-' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Tem certeza que deseja excluir esta Mureg?
-
-
Cliente: {{ deleteTarget?.cliente || '-' }}
-
Linha nova: {{ deleteTarget?.linhaNova || '-' }}
-
Linha antiga: {{ deleteTarget?.linhaAntiga || '-' }}
-
-
-
-
- Cancelar
-
-
- Excluir
- Excluindo...
-
-
-
-
-
-
-
+
diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss
index 3e30c8f..186021a 100644
--- a/src/app/pages/mureg/mureg.scss
+++ b/src/app/pages/mureg/mureg.scss
@@ -277,8 +277,6 @@
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODALS */
-.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
-.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
.modal-card.modal-xl-custom { width: min(920px, 90vw); max-height: 78vh; }
.modal-card.modal-sm { width: min(480px, 100%); }
diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts
index a09f8f8..aee7365 100644
--- a/src/app/pages/mureg/mureg.ts
+++ b/src/app/pages/mureg/mureg.ts
@@ -10,10 +10,22 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
+import { TableExportService } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
+import { MuregModalsComponent } from '../../components/page-modals/mureg-modals/mureg-modals';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+import { buildApiEndpoint } from '../../utils/api-base.util';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
@@ -75,15 +87,28 @@ interface MuregDetailDto {
statusNaGeral: string | null;
}
+type MuregExportRow = MuregRow & {
+ usuario?: string | null;
+ skil?: string | null;
+ linhaAtualNaGeral?: string | null;
+ chipNaGeral?: string | null;
+ contaNaGeral?: string | null;
+ statusNaGeral?: string | null;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+};
+
@Component({
standalone: true,
- imports: [CommonModule, FormsModule, CustomSelectComponent],
+ imports: [CommonModule, FormsModule, CustomSelectComponent, MuregModalsComponent],
templateUrl: './mureg.html',
styleUrls: ['./mureg.scss']
})
export class Mureg implements AfterViewInit {
+ readonly vm = this;
toastMessage = '';
loading = false;
+ exporting = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@@ -91,14 +116,12 @@ export class Mureg implements AfterViewInit {
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef,
- private linesService: LinesService
+ private authService: AuthService,
+ private linesService: LinesService,
+ private tableExportService: TableExportService
) {}
- private readonly apiBase = (() => {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
- return `${apiBase}/mureg`;
- })();
+ private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'mureg');
// ====== DATA ======
clientGroups: ClientGroup[] = [];
@@ -162,9 +185,20 @@ export class Mureg implements AfterViewInit {
clienteInfo: ''
};
+ isSysAdmin = false;
+ isGestor = false;
+ isFinanceiro = false;
+
+ get canManageRecords(): boolean {
+ return this.isSysAdmin || this.isGestor;
+ }
+
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
+ this.isSysAdmin = this.authService.hasRole('sysadmin');
+ this.isGestor = this.authService.hasRole('gestor');
+ this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => {
this.preloadClients(); // ✅ já deixa o select pronto
this.refresh();
@@ -184,6 +218,147 @@ export class Mureg implements AfterViewInit {
this.loadForGroups();
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const baseRows = await this.fetchAllRowsForExport();
+ const rows = await this.fetchDetailedRowsForExport(baseRows);
+ if (!rows.length) {
+ await this.showToast('Nenhum registro encontrado para exportar.');
+ return;
+ }
+
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `mureg_${timestamp}`,
+ sheetName: 'Mureg',
+ rows,
+ columns: [
+ { header: 'ID', value: (row) => row.id ?? '' },
+ { header: 'Cliente', value: (row) => row.cliente },
+ { header: 'Usuario', value: (row) => row.usuario ?? '' },
+ { header: 'Skil', value: (row) => row.skil ?? '' },
+ { header: 'Item', type: 'number', value: (row) => this.toIntOrZero(row.item) },
+ { header: 'Linha Antiga', value: (row) => row.linhaAntiga },
+ { header: 'Linha Nova', value: (row) => row.linhaNova },
+ { header: 'ICCID', value: (row) => row.iccid },
+ { header: 'Data da Mureg', type: 'date', value: (row) => row.dataDaMureg },
+ { header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') },
+ { header: 'Linha ID (Geral)', value: (row) => row.mobileLineId ?? '' },
+ { header: 'Linha Atual na Geral', value: (row) => row.linhaAtualNaGeral ?? '' },
+ { header: 'Chip na Geral', value: (row) => row.chipNaGeral ?? '' },
+ { header: 'Conta na Geral', value: (row) => row.contaNaGeral ?? '' },
+ { header: 'Status na Geral', value: (row) => row.statusNaGeral ?? '' },
+ { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
+ { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
+ ],
+ });
+
+ await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
+ } catch {
+ await this.showToast('Erro ao exportar planilha.');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private async fetchAllRowsForExport(): Promise {
+ const pageSize = 2000;
+ let page = 1;
+ let expectedTotal = 0;
+ const rows: MuregRow[] = [];
+
+ while (page <= 500) {
+ const params = new HttpParams()
+ .set('page', String(page))
+ .set('pageSize', String(pageSize))
+ .set('search', (this.searchTerm ?? '').trim())
+ .set('sortBy', 'cliente')
+ .set('sortDir', 'asc');
+
+ const response = await firstValueFrom(
+ this.http.get | any[]>(this.apiBase, { params })
+ );
+
+ const items = Array.isArray(response) ? response : (response.items ?? []);
+ const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx));
+ rows.push(...normalized);
+ expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0);
+
+ if (Array.isArray(response)) break;
+ if (items.length === 0) break;
+ if (items.length < pageSize) break;
+ if (expectedTotal > 0 && rows.length >= expectedTotal) break;
+ page += 1;
+ }
+
+ return rows.sort((a, b) => {
+ const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' });
+ if (byClient !== 0) return byClient;
+
+ const byItem = this.toIntOrZero(a.item) - this.toIntOrZero(b.item);
+ if (byItem !== 0) return byItem;
+
+ return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' });
+ });
+ }
+
+ private async fetchDetailedRowsForExport(rows: MuregRow[]): Promise {
+ if (!rows.length) return [];
+
+ const result: MuregExportRow[] = [];
+ const chunkSize = 10;
+
+ for (let i = 0; i < rows.length; i += chunkSize) {
+ const chunk = rows.slice(i, i + chunkSize);
+ const detailedChunk = await Promise.all(
+ chunk.map(async (row) => {
+ try {
+ const detail = await firstValueFrom(this.http.get(`${this.apiBase}/${row.id}`));
+ const merged: MuregExportRow = {
+ ...row,
+ item: detail.item !== undefined && detail.item !== null ? String(detail.item) : row.item,
+ linhaAntiga: detail.linhaAntiga ?? row.linhaAntiga,
+ linhaNova: detail.linhaNova ?? row.linhaNova,
+ iccid: detail.iccid ?? row.iccid,
+ dataDaMureg: detail.dataDaMureg ?? row.dataDaMureg,
+ cliente: detail.cliente ?? row.cliente,
+ mobileLineId: detail.mobileLineId ?? row.mobileLineId,
+ usuario: detail.usuario ?? null,
+ skil: detail.skil ?? null,
+ linhaAtualNaGeral: detail.linhaAtualNaGeral ?? null,
+ chipNaGeral: detail.chipNaGeral ?? null,
+ contaNaGeral: detail.contaNaGeral ?? null,
+ statusNaGeral: detail.statusNaGeral ?? null,
+ createdAt: this.getRawField(detail, ['createdAt', 'CreatedAt']) ?? this.getRawField(row.raw, ['createdAt', 'CreatedAt']),
+ updatedAt: this.getRawField(detail, ['updatedAt', 'UpdatedAt']) ?? this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']),
+ };
+
+ return merged;
+ } catch {
+ return {
+ ...row,
+ usuario: this.getRawField(row.raw, ['usuario', 'Usuario']),
+ skil: this.getRawField(row.raw, ['skil', 'Skil']),
+ linhaAtualNaGeral: this.getRawField(row.raw, ['linhaAtualNaGeral', 'LinhaAtualNaGeral']),
+ chipNaGeral: this.getRawField(row.raw, ['chipNaGeral', 'ChipNaGeral']),
+ contaNaGeral: this.getRawField(row.raw, ['contaNaGeral', 'ContaNaGeral']),
+ statusNaGeral: this.getRawField(row.raw, ['statusNaGeral', 'StatusNaGeral']),
+ createdAt: this.getRawField(row.raw, ['createdAt', 'CreatedAt']),
+ updatedAt: this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']),
+ };
+ }
+ })
+ );
+
+ result.push(...detailedChunk);
+ }
+
+ return result;
+ }
+
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
@@ -208,29 +383,20 @@ export class Mureg implements AfterViewInit {
}
goToPage(p: number) {
- this.page = Math.max(1, Math.min(this.totalPages, p));
+ this.page = clampPage(p, this.totalPages);
this.applyPagination();
}
- get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
+ get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); }
get pageNumbers() {
- 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;
+ return buildPageNumbers(this.page, this.totalPages);
}
- get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
+ get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); }
get pageEnd() {
- if (this.total === 0) return 0;
- return Math.min(this.page * this.pageSize, this.total);
+ return computePageEnd(this.total || 0, this.page, this.pageSize);
}
trackById(_: number, row: MuregRow) { return row.id; }
@@ -468,6 +634,11 @@ export class Mureg implements AfterViewInit {
// CREATE MODAL
// =======================================================================
onCreate() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.preloadClients();
this.createOpen = true;
@@ -480,7 +651,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: '',
linhaNova: '',
iccid: '',
- dataDaMureg: '',
+ dataDaMureg: this.nowDateInput(),
clienteInfo: ''
};
@@ -507,6 +678,11 @@ export class Mureg implements AfterViewInit {
}
saveCreate() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
const mobileLineId = String(this.createModel.mobileLineId ?? '').trim();
const linhaNova = String(this.createModel.linhaNova ?? '').trim();
@@ -523,7 +699,7 @@ export class Mureg implements AfterViewInit {
linhaAntiga: (this.createModel.linhaAntiga ?? '') || null,
linhaNova: (this.createModel.linhaNova ?? '') || null,
iccid: (this.createModel.iccid ?? '') || null,
- dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg)
+ dataDaMureg: new Date().toISOString()
};
if (!payload.item || payload.item <= 0) delete payload.item;
@@ -547,6 +723,11 @@ export class Mureg implements AfterViewInit {
// EDIT MODAL
// =======================================================================
onEditar(r: MuregRow) {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.preloadClients();
this.editOpen = true;
@@ -614,6 +795,11 @@ export class Mureg implements AfterViewInit {
}
saveEdit() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
if (!this.editModel || !this.editModel.id) return;
const mobileLineId = String(this.editModel.mobileLineId ?? '').trim();
@@ -688,6 +874,11 @@ export class Mureg implements AfterViewInit {
// DELETE MODAL
// =======================================================================
onDelete(row: MuregRow) {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.deleteTarget = row;
this.deleteOpen = true;
this.deleteSaving = false;
@@ -700,6 +891,11 @@ export class Mureg implements AfterViewInit {
}
async confirmDelete() {
+ if (!this.canManageRecords) {
+ await this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
if (!this.deleteTarget?.id) return;
if (!(await confirmDeletionWithTyping('esta Mureg'))) return;
@@ -758,6 +954,14 @@ export class Mureg implements AfterViewInit {
return dt.toISOString();
}
+ private nowDateInput(): string {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const day = String(now.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+
private extractApiMessage(err: any): string | null {
try {
const m1 = err?.error?.message;
@@ -770,6 +974,15 @@ export class Mureg implements AfterViewInit {
}
}
+ private getRawField(source: any, keys: string[]): string | null {
+ for (const key of keys) {
+ const value = source?.[key];
+ if (value === undefined || value === null || String(value).trim() === '') continue;
+ return String(value);
+ }
+ return null;
+ }
+
displayValue(key: MuregKey, v: any): string {
if (v === null || v === undefined || String(v).trim() === '') return '-';
diff --git a/src/app/pages/mve-auditoria/mve-auditoria.html b/src/app/pages/mve-auditoria/mve-auditoria.html
new file mode 100644
index 0000000..26ccc58
--- /dev/null
+++ b/src/app/pages/mve-auditoria/mve-auditoria.html
@@ -0,0 +1,331 @@
+
+
+
+
+ {{ toastMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No sistema
+ {{ audit.summary.totalSystemLines }}
+
+
+ No relatorio
+ {{ audit.summary.totalReportLines }}
+
+
+ Sem diferenca
+ {{ audit.summary.totalConciliated }}
+
+
+ Com diferenca
+ {{ totalDifferencesCount }}
+
+
+ Prontas para atualizar
+ {{ syncableIssues.length }}
+
+
+ Revisão manual
+ {{ manualReviewIssuesCount }}
+
+
+
+
0 || audit.summary.totalOnlyInReport > 0 || ignoredIssuesCount > 0">
+ 0">Só no sistema: {{ audit.summary.totalOnlyInSystem }}
+ 0">Só no relatório: {{ audit.summary.totalOnlyInReport }}
+ 0">Avisos/ignorados: {{ ignoredIssuesCount }}
+
+
+
+
+
+
+
Nenhuma divergência encontrada para o filtro atual.
+
+
+
0">
+
+
+
+ Número
+ Sistema
+ Relatório
+ Situação
+ Ação
+
+
+
+
+
+
+ {{ issue.numeroLinha || issue.reportSnapshot?.numeroLinha || issue.systemSnapshot?.numeroLinha || '-' }}
+
+
+
+
+
+ Sistema
+ Cadastro atual
+
+
+
+ Linha
+ Alterada
+
+
{{ formatValue(issue.systemSnapshot?.numeroLinha) }}
+
+
+
+ Chip
+ Alterado
+
+
{{ formatValue(issue.systemSnapshot?.chip) }}
+
+
+
+ Status
+ Divergente
+
+
{{ statusLabel(issue.systemStatus) }}
+
+
+
+
+
+
+ Relatório
+ Importado do MVE
+
+
+
+ Linha
+ Nova
+
+
{{ formatValue(issue.reportSnapshot?.numeroLinha) }}
+
+
+
+ Chip
+ Novo
+
+
{{ formatValue(issue.reportSnapshot?.chip) }}
+
+
+
+ Status
+ MVE
+
+
{{ statusLabel(issue.reportStatus) }}
+
+
+
+
+
+
+ {{ issueKindLabel(issue) }}
+
+
0">
+
+
{{ issue.notes }}
+
+
+
+
+ Pode atualizar
+ Atualizada
+ Revisar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nenhuma conferencia carregada ainda.
+
Envie o relatorio da Vivo para ver divergências de status, linha e chip antes de atualizar o sistema.
+
+
+
+
+
diff --git a/src/app/pages/mve-auditoria/mve-auditoria.scss b/src/app/pages/mve-auditoria/mve-auditoria.scss
new file mode 100644
index 0000000..2945256
--- /dev/null
+++ b/src/app/pages/mve-auditoria/mve-auditoria.scss
@@ -0,0 +1,949 @@
+:host {
+ --brand: #e33dcf;
+ --brand-deep: #972688;
+ --brand-soft: rgba(227, 61, 207, 0.1);
+ --ink: #17161d;
+ --muted: rgba(23, 22, 29, 0.68);
+ --surface: rgba(255, 255, 255, 0.84);
+ --surface-strong: rgba(255, 255, 255, 0.94);
+ --line: rgba(23, 22, 29, 0.08);
+ --shadow: 0 24px 60px rgba(24, 17, 33, 0.12);
+ --success: #198754;
+ --danger: #dc3545;
+ --warning: #ffb200;
+
+ display: block;
+ color: var(--ink);
+ font-family: 'Inter', sans-serif;
+}
+
+.mve-page {
+ min-height: 100vh;
+ position: relative;
+ overflow: hidden;
+ padding: 0 14px 120px;
+ background:
+ radial-gradient(720px 380px at 12% 8%, rgba(227, 61, 207, 0.16), transparent 60%),
+ radial-gradient(620px 360px at 88% 12%, rgba(3, 15, 170, 0.08), transparent 58%),
+ linear-gradient(180deg, #fff 0%, #f6f4f8 100%);
+}
+
+.page-blob {
+ position: fixed;
+ border-radius: 999px;
+ filter: blur(48px);
+ pointer-events: none;
+ opacity: 0.42;
+ z-index: 0;
+ background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.6), rgba(227, 61, 207, 0.04));
+
+ &.blob-1 { width: 420px; height: 420px; top: -180px; left: -120px; }
+ &.blob-2 { width: 520px; height: 520px; top: -220px; right: -220px; }
+ &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 20%; }
+ &.blob-4 { width: 420px; height: 420px; bottom: -200px; right: 12%; }
+}
+
+.page-shell {
+ position: relative;
+ z-index: 1;
+ width: min(1480px, 98vw);
+ margin: 38px auto 0;
+}
+
+.page-card {
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.72);
+ border-radius: 28px;
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(12px);
+ overflow: hidden;
+}
+
+.page-header {
+ padding: 22px 24px 18px;
+ border-bottom: 1px solid var(--line);
+ background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.72));
+ display: grid;
+ justify-items: center;
+}
+
+.page-header > * {
+ width: min(1120px, 100%);
+}
+
+.header-top {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 16px;
+ align-items: center;
+ justify-items: center;
+ text-align: center;
+}
+
+.title-badge {
+ justify-self: center;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.92);
+ border: 1px solid rgba(227, 61, 207, 0.22);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
+ font-size: 13px;
+ font-weight: 800;
+
+ i {
+ color: var(--brand);
+ }
+}
+
+.header-title {
+ text-align: center;
+}
+
+.title {
+ font-size: 28px;
+ font-weight: 950;
+ letter-spacing: -0.04em;
+ color: var(--ink);
+}
+
+.subtitle {
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.header-actions {
+ justify-self: center;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 10px;
+}
+
+.btn {
+ border-radius: 14px;
+ font-weight: 800;
+}
+
+.btn-glass {
+ background: rgba(255, 255, 255, 0.92);
+ border: 1px solid rgba(23, 22, 29, 0.08);
+ color: var(--ink);
+}
+
+.btn-brand {
+ background: linear-gradient(135deg, var(--brand), var(--brand-deep));
+ border: 0;
+ color: #fff;
+ box-shadow: 0 12px 24px rgba(151, 38, 136, 0.24);
+}
+
+.intro-card,
+.upload-card,
+.secondary-notes,
+.toolbar,
+.summary-grid,
+.table-wrap,
+.status-empty,
+.empty-state {
+ margin-top: 18px;
+}
+
+.intro-card,
+.upload-card,
+.toolbar,
+.table-wrap,
+.status-empty,
+.empty-state {
+ background: var(--surface-strong);
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ box-shadow: 0 18px 34px rgba(24, 17, 33, 0.08);
+}
+
+.intro-card {
+ padding: 18px 20px;
+ display: grid;
+ gap: 14px;
+ text-align: center;
+}
+
+.intro-title,
+.section-title {
+ font-size: 14px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--brand-deep);
+}
+
+.intro-text,
+.section-subtitle {
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.intro-meta,
+.secondary-notes {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.meta-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ background: rgba(24, 17, 33, 0.05);
+ border: 1px solid rgba(24, 17, 33, 0.08);
+ font-size: 12px;
+ font-weight: 700;
+ color: rgba(24, 17, 33, 0.78);
+}
+
+.upload-card {
+ padding: 20px;
+ text-align: center;
+}
+
+.section-head {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.upload-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 16px;
+ margin-top: 16px;
+ align-items: center;
+}
+
+.upload-zone {
+ display: grid;
+ place-items: center;
+ gap: 8px;
+ min-height: 180px;
+ padding: 20px;
+ border-radius: 22px;
+ border: 1px dashed rgba(227, 61, 207, 0.35);
+ background:
+ linear-gradient(135deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.85)),
+ #fff;
+ text-align: center;
+ cursor: pointer;
+
+ input {
+ display: none;
+ }
+}
+
+.upload-icon {
+ width: 62px;
+ height: 62px;
+ display: grid;
+ place-items: center;
+ border-radius: 20px;
+ background: rgba(227, 61, 207, 0.12);
+ color: var(--brand-deep);
+ font-size: 28px;
+}
+
+.upload-title {
+ font-size: 18px;
+ font-weight: 900;
+ color: var(--ink);
+}
+
+.upload-subtitle {
+ max-width: 540px;
+ color: var(--muted);
+ line-height: 1.45;
+}
+
+.upload-actions {
+ display: grid;
+ gap: 10px;
+ justify-content: center;
+}
+
+.apply-banner {
+ margin-top: 14px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ background: rgba(25, 135, 84, 0.1);
+ border: 1px solid rgba(25, 135, 84, 0.18);
+ color: #11653d;
+ font-weight: 700;
+}
+
+.page-body {
+ padding: 0 24px 24px;
+}
+
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.summary-card {
+ background: rgba(255, 255, 255, 0.94);
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ padding: 18px;
+ display: grid;
+ gap: 10px;
+ box-shadow: 0 16px 30px rgba(24, 17, 33, 0.08);
+
+ strong {
+ font-size: 30px;
+ line-height: 1;
+ letter-spacing: -0.05em;
+ }
+
+ &.is-positive strong {
+ color: var(--success);
+ }
+
+ &.is-danger strong {
+ color: var(--danger);
+ }
+
+ &.is-brand strong {
+ color: var(--brand-deep);
+ }
+}
+
+.summary-label {
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.toolbar {
+ padding: 14px 16px;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ 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);
+ color: var(--ink);
+ border-radius: 999px;
+ padding: 8px 14px;
+ font-size: 12px;
+ font-weight: 800;
+
+ &.active {
+ background: rgba(227, 61, 207, 0.14);
+ border-color: rgba(227, 61, 207, 0.24);
+ color: var(--brand-deep);
+ }
+}
+
+.toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.search-group {
+ min-width: min(360px, 86vw);
+}
+
+.page-size-select {
+ min-width: 140px;
+}
+
+.table-wrap {
+ overflow: hidden;
+ max-width: 1120px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.table-modern {
+ margin: 0;
+ 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);
+ color: rgba(24, 17, 33, 0.72);
+ font-size: 12px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 14px 16px;
+ 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);
+ vertical-align: middle;
+ text-align: center;
+ }
+}
+
+.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;
+ justify-content: center;
+ min-width: 120px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: rgba(3, 15, 170, 0.08);
+ border: 1px solid rgba(3, 15, 170, 0.18);
+ color: #030faa;
+ font-size: 0.83rem;
+ font-weight: 950;
+ letter-spacing: 0.02em;
+ box-shadow:
+ 0 8px 18px rgba(3, 15, 170, 0.08),
+ 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;
+ justify-content: center;
+ padding: 8px 14px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 900;
+ border: 1px solid transparent;
+
+ &.is-active {
+ background: rgba(25, 135, 84, 0.12);
+ color: #11653d;
+ border-color: rgba(25, 135, 84, 0.18);
+ }
+
+ &.is-blocked {
+ background: rgba(220, 53, 69, 0.12);
+ color: #9f1d2d;
+ border-color: rgba(220, 53, 69, 0.18);
+ }
+
+ &.is-neutral {
+ background: rgba(24, 17, 33, 0.06);
+ color: rgba(24, 17, 33, 0.72);
+ border-color: rgba(24, 17, 33, 0.08);
+ }
+}
+
+.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: 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;
+ justify-content: center;
+ padding: 7px 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 900;
+
+ &.ready {
+ background: rgba(255, 178, 0, 0.16);
+ color: #8c6200;
+ font-size: 11px;
+ }
+
+ &.applied {
+ background: rgba(25, 135, 84, 0.14);
+ color: #11653d;
+ }
+
+ &.muted {
+ background: rgba(24, 17, 33, 0.06);
+ color: rgba(24, 17, 33, 0.62);
+ }
+}
+
+.page-footer {
+ margin-top: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.status-empty,
+.empty-state {
+ padding: 42px 20px;
+ text-align: center;
+ color: var(--muted);
+ display: grid;
+ gap: 8px;
+ place-items: center;
+
+ i {
+ font-size: 34px;
+ color: var(--brand-deep);
+ }
+}
+
+.empty-state small {
+ max-width: 560px;
+ line-height: 1.5;
+}
+
+@media (max-width: 1100px) {
+ .header-top {
+ grid-template-columns: 1fr;
+ text-align: center;
+ }
+
+ .title-badge,
+ .header-actions {
+ justify-self: center;
+ }
+
+ .summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .upload-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 720px) {
+ .page-shell {
+ width: calc(100vw - 16px);
+ margin-top: 18px;
+ }
+
+ .page-header,
+ .page-body {
+ padding-left: 14px;
+ padding-right: 14px;
+ }
+
+ .summary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .page-card {
+ border-radius: 20px;
+ }
+
+ .page-header > *,
+ .page-body > * {
+ width: 100%;
+ }
+
+ .toolbar-right,
+ .header-actions {
+ width: 100%;
+ }
+
+ .header-actions .btn,
+ .upload-actions .btn {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .upload-zone {
+ min-height: 150px;
+ padding: 18px 14px;
+ }
+
+ .upload-title {
+ font-size: 16px;
+ }
+
+ .intro-meta,
+ .secondary-notes,
+ .view-tabs,
+ .toolbar,
+ .type-filters {
+ justify-content: center;
+ }
+
+ .view-tabs {
+ 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%;
+ min-width: 0;
+ }
+
+ .table-wrap {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ border-radius: 18px;
+ }
+
+ .table-modern {
+ min-width: 1180px;
+ }
+
+ .line-number-chip {
+ min-width: 108px;
+ font-size: 0.78rem;
+ padding: 7px 12px;
+ }
+
+ .page-footer {
+ justify-content: center;
+ text-align: center;
+ }
+}
+
+@media (max-width: 420px) {
+ .mve-page {
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+
+ .page-header,
+ .page-body {
+ padding-left: 12px;
+ padding-right: 12px;
+ }
+
+ .title {
+ font-size: 24px;
+ }
+
+ .subtitle {
+ font-size: 0.88rem;
+ }
+
+ .title-badge,
+ .meta-pill,
+ .filter-tab,
+ .status-pill,
+ .sync-badge {
+ font-size: 11px;
+ }
+
+ .page-footer .pagination {
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+}
diff --git a/src/app/pages/mve-auditoria/mve-auditoria.ts b/src/app/pages/mve-auditoria/mve-auditoria.ts
new file mode 100644
index 0000000..c30128a
--- /dev/null
+++ b/src/app/pages/mve-auditoria/mve-auditoria.ts
@@ -0,0 +1,477 @@
+import { Component, ChangeDetectorRef, ElementRef, Inject, OnInit, PLATFORM_ID, ViewChild } 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 {
+ MveAuditService,
+ type ApplyMveAuditResult,
+ type MveAuditIssue,
+ type MveAuditRun,
+} from '../../services/mve-audit.service';
+import { confirmActionModal } from '../../utils/destructive-confirmation';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages,
+} from '../../utils/pagination.util';
+
+type MveIssueViewMode = 'PENDING' | 'APPLIED' | 'ALL';
+type MveIssueCategory = 'ALL' | 'STATUS' | 'LINE' | 'CHIP';
+
+@Component({
+ selector: 'app-mve-auditoria',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ templateUrl: './mve-auditoria.html',
+ styleUrls: ['./mve-auditoria.scss'],
+})
+export class MveAuditoriaPage implements OnInit {
+ @ViewChild('feedbackToast', { static: false }) feedbackToast?: ElementRef;
+
+ loadingLatest = false;
+ processing = false;
+ syncing = false;
+ selectedFile: File | null = null;
+ auditResult: MveAuditRun | null = null;
+ applyResult: ApplyMveAuditResult | null = null;
+ errorMessage = '';
+ toastMessage = '';
+
+ searchTerm = '';
+ viewMode: MveIssueViewMode = 'PENDING';
+ issueCategory: MveIssueCategory = 'ALL';
+ page = 1;
+ pageSize = 20;
+ readonly pageSizeOptions = [10, 20, 50, 100];
+
+ constructor(
+ private readonly mveAuditService: MveAuditService,
+ private readonly cdr: ChangeDetectorRef,
+ @Inject(PLATFORM_ID) private readonly platformId: object
+ ) {}
+
+ ngOnInit(): void {
+ this.errorMessage = '';
+ const cachedRun = this.mveAuditService.getCachedRun();
+ if (cachedRun) {
+ this.auditResult = cachedRun;
+ return;
+ }
+
+ void this.restoreCachedAudit();
+ }
+
+ get hasAuditResult(): boolean {
+ return !!this.auditResult;
+ }
+
+ get syncableIssues(): MveAuditIssue[] {
+ return this.relevantIssues.filter((issue) => issue.syncable && !issue.applied);
+ }
+
+ get relevantIssues(): MveAuditIssue[] {
+ const issues = this.auditResult?.issues ?? [];
+ return issues
+ .filter((issue) => this.issueHasRelevantDifference(issue))
+ .sort((left, right) => Number(left.applied) - Number(right.applied));
+ }
+
+ get filteredIssues(): MveAuditIssue[] {
+ const query = this.normalizeSearch(this.searchTerm);
+ 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(' ');
+
+ return haystack.includes(query);
+ });
+ }
+
+ get pagedIssues(): MveAuditIssue[] {
+ const offset = (this.page - 1) * this.pageSize;
+ return this.filteredIssues.slice(offset, offset + this.pageSize);
+ }
+
+ get totalPages(): number {
+ return computeTotalPages(this.filteredIssues.length, this.pageSize);
+ }
+
+ get pageNumbers(): number[] {
+ return buildPageNumbers(this.page, this.totalPages);
+ }
+
+ get pageStart(): number {
+ return computePageStart(this.filteredIssues.length, this.page, this.pageSize);
+ }
+
+ get pageEnd(): number {
+ 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 {
+ if (!this.auditResult) return 0;
+ const summary = this.auditResult.summary;
+ return (
+ summary.totalOnlyInSystem +
+ summary.totalOnlyInReport +
+ summary.totalDuplicateReportLines +
+ summary.totalDuplicateSystemLines +
+ summary.totalInvalidRows +
+ summary.totalUnknownStatuses
+ );
+ }
+
+ async loadLatestAudit(): Promise {
+ this.loadingLatest = true;
+ this.errorMessage = '';
+
+ 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;
+ if (httpError?.status !== 404) {
+ this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel carregar a ultima conferencia.');
+ }
+ this.auditResult = null;
+ } finally {
+ this.loadingLatest = false;
+ }
+ }
+
+ onFileSelected(event: Event): void {
+ const input = event.target as HTMLInputElement | null;
+ const file = input?.files?.[0] ?? null;
+
+ this.errorMessage = '';
+ this.applyResult = null;
+
+ if (!file) {
+ this.selectedFile = null;
+ return;
+ }
+
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ this.selectedFile = null;
+ this.errorMessage = 'Selecione o relatorio exportado pela Vivo.';
+ return;
+ }
+
+ if (file.size <= 0) {
+ this.selectedFile = null;
+ this.errorMessage = 'O arquivo selecionado está vazio.';
+ return;
+ }
+
+ if (file.size > 20 * 1024 * 1024) {
+ this.selectedFile = null;
+ this.errorMessage = 'O arquivo excede o limite de 20 MB.';
+ return;
+ }
+
+ this.selectedFile = file;
+ }
+
+ clearSelectedFile(): void {
+ this.selectedFile = null;
+ this.errorMessage = '';
+ }
+
+ async processAudit(): Promise {
+ if (!this.selectedFile || this.processing || this.syncing) {
+ return;
+ }
+
+ this.processing = true;
+ this.errorMessage = '';
+ this.applyResult = null;
+
+ try {
+ this.auditResult = await firstValueFrom(this.mveAuditService.preview(this.selectedFile));
+ this.issueCategory = 'ALL';
+ this.page = 1;
+ this.viewMode = 'PENDING';
+ this.searchTerm = '';
+ await this.showToast('Relatorio conferido com sucesso.');
+ } catch (error) {
+ this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel conferir o relatorio.');
+ } finally {
+ this.processing = false;
+ }
+ }
+
+ async syncIssues(): Promise {
+ if (!this.auditResult || this.syncableIssues.length === 0 || this.syncing) {
+ return;
+ }
+
+ const confirmed = await confirmActionModal({
+ 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',
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ this.syncing = true;
+ this.errorMessage = '';
+
+ try {
+ this.applyResult = await firstValueFrom(this.mveAuditService.apply(this.auditResult.id));
+ this.auditResult = await firstValueFrom(this.mveAuditService.getById(this.auditResult.id));
+ this.viewMode = 'ALL';
+ this.page = 1;
+ await this.showToast('Atualizações aplicadas com sucesso.');
+ } catch (error) {
+ this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar o sistema.');
+ } finally {
+ this.syncing = false;
+ }
+ }
+
+ onSearchChange(): void {
+ this.page = 1;
+ }
+
+ onPageSizeChange(): void {
+ this.page = 1;
+ }
+
+ 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);
+ }
+
+ trackByIssue(_: number, issue: MveAuditIssue): string {
+ return issue.id;
+ }
+
+ formatDateTime(value?: string | null): string {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '-';
+ return new Intl.DateTimeFormat('pt-BR', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(date);
+ }
+
+ statusClass(status: string | null | undefined): string {
+ const normalized = (status ?? '').toLowerCase();
+ if (normalized.includes('bloq') || normalized.includes('perda') || normalized.includes('roubo')) return 'is-blocked';
+ if (normalized.includes('ativo')) return 'is-active';
+ return 'is-neutral';
+ }
+
+ statusLabel(status: string | null | undefined): string {
+ const value = (status ?? '').trim();
+ return value || '-';
+ }
+
+ 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 {
+ const cachedRunId = this.mveAuditService.getCachedRunId();
+ if (!cachedRunId) {
+ return;
+ }
+
+ this.loadingLatest = true;
+
+ try {
+ const restoredRun = await firstValueFrom(this.mveAuditService.restoreCachedRun());
+ if (!restoredRun) {
+ return;
+ }
+
+ 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()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .trim();
+ }
+
+ private extractHttpMessage(error: unknown, fallbackMessage: string): string {
+ const httpError = error as HttpErrorResponse | null;
+ if (httpError?.status === 0) {
+ return 'A API do LineGestao nao respondeu em http://localhost:5298. Inicie o backend e tente novamente.';
+ }
+
+ return (
+ (httpError?.error as { message?: string } | null)?.message ||
+ httpError?.message ||
+ fallbackMessage
+ );
+ }
+
+ private async showToast(message: string): Promise {
+ if (!isPlatformBrowser(this.platformId)) return;
+
+ this.toastMessage = message;
+ this.cdr.detectChanges();
+
+ if (!this.feedbackToast?.nativeElement) return;
+
+ try {
+ const bootstrap = await import('bootstrap');
+ const instance = bootstrap.Toast.getOrCreateInstance(this.feedbackToast.nativeElement, {
+ autohide: true,
+ delay: 3200,
+ });
+ instance.show();
+ } catch {
+ // ignora falha de feedback visual
+ }
+ }
+}
diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html
index a9517bc..fa6e752 100644
--- a/src/app/pages/notificacoes/notificacoes.html
+++ b/src/app/pages/notificacoes/notificacoes.html
@@ -69,7 +69,7 @@
diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss
index 435bc37..9344fb1 100644
--- a/src/app/pages/notificacoes/notificacoes.scss
+++ b/src/app/pages/notificacoes/notificacoes.scss
@@ -96,6 +96,13 @@ $border: #e5e7eb;
&:disabled { opacity: 0.6; cursor: default; }
&.ghost { background: transparent; }
+ &.export-glass {
+ background: rgba(255, 255, 255, 0.7);
+ border-color: rgba(28, 56, 201, 0.22);
+ color: $primary;
+ font-weight: 800;
+ box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
+ }
}
/* FILTROS (Estilo Tabs/Pills) */
diff --git a/src/app/pages/parcelamentos/parcelamentos.html b/src/app/pages/parcelamentos/parcelamentos.html
index 1697b20..c12e0fa 100644
--- a/src/app/pages/parcelamentos/parcelamentos.html
+++ b/src/app/pages/parcelamentos/parcelamentos.html
@@ -1,4 +1,14 @@
+
+
@@ -65,7 +79,8 @@
[total]="total"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
- [isSysAdmin]="isSysAdmin"
+ [canEdit]="$any(canManageRecords)"
+ [canDelete]="$any(isSysAdmin)"
(segmentChange)="setSegment($event)"
(detail)="openDetails($event)"
(edit)="openEdit($event)"
@@ -77,175 +92,4 @@
-
-
-
-
-
-
-
-
-
-
Carregando detalhes...
-
-
-
-
- {{ detailError }}
-
-
-
-
-
- Cliente
- {{ detail.cliente || '-' }}
-
-
- Linha
- {{ detail.linha || '-' }}
-
-
- AnoRef
- {{ detail.anoRef ?? '-' }}
-
-
- Item
- {{ detail.item ?? '-' }}
-
-
- Qt Parcelas
- {{ displayQtParcelas(detail) }}
-
-
- Parcela Atual
- {{ detail.parcelaAtual ?? '-' }}
-
-
- Total Parcelas
- {{ detail.totalParcelas ?? '-' }}
-
-
- Status
- {{ detailStatus }}
-
-
- Valor Cheio
- {{ formatMoney(detail.valorCheio) }}
-
-
- Desconto
- {{ formatMoney(detail.desconto) }}
-
-
- Valor com Desconto
- {{ formatMoney(detail.valorComDesconto) }}
-
-
-
-
-
-
-
- Detalhamento anual
-
-
-
-
0; else annualEmpty">
-
-
-
- Ano
- Total
- {{ m.label }}
-
-
-
-
- {{ row.year }}
- {{ row.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}
-
- {{ m.value !== null && m.value !== undefined ? (m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR') : '-' }}
-
-
-
-
-
-
-
-
- Sem dados anuais.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Confirma remover o parcelamento {{ deleteTarget?.linha }} ?
-
{{ deleteError }}
-
-
-
-
-
+
diff --git a/src/app/pages/parcelamentos/parcelamentos.scss b/src/app/pages/parcelamentos/parcelamentos.scss
index 6c25851..d606fec 100644
--- a/src/app/pages/parcelamentos/parcelamentos.scss
+++ b/src/app/pages/parcelamentos/parcelamentos.scss
@@ -208,6 +208,13 @@
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
}
+.btn-export-glass {
+ color: var(--pg-primary);
+ background: rgba(255, 255, 255, 0.68);
+ border-color: rgba(31, 79, 214, 0.24);
+ font-weight: 900;
+}
+
.btn-danger {
color: #fff;
background: linear-gradient(145deg, #cf3131, #a91f1f);
@@ -226,24 +233,10 @@
color: var(--pg-primary-strong);
}
-.lg-backdrop {
- position: fixed;
- inset: 0;
- background:
- radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%),
- rgba(15, 23, 42, 0.6);
- z-index: 9990;
- backdrop-filter: blur(4px);
-}
-
-.lg-modal {
- position: fixed;
- inset: 0;
- z-index: 9995;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
+.btn-export-glass:hover {
+ background: #fff;
+ border-color: rgba(227, 61, 207, 0.3);
+ color: #e33dcf;
}
.lg-modal-card {
diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts
index 0b2d3f1..30ec4bc 100644
--- a/src/app/pages/parcelamentos/parcelamentos.ts
+++ b/src/app/pages/parcelamentos/parcelamentos.ts
@@ -2,8 +2,10 @@ import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { environment } from '../../../environments/environment';
-import { finalize, Subscription, timeout } from 'rxjs';
+import { finalize, Subscription, firstValueFrom, timeout } from 'rxjs';
+import { ParcelamentosModalsComponent } from '../../components/page-modals/parcelamento-modals/parcelamentos-modals';
import { AuthService } from '../../services/auth.service';
+import { TableExportService } from '../../services/table-export.service';
import {
ParcelamentosService,
ParcelamentoListItem,
@@ -18,27 +20,35 @@ import {
import {
ParcelamentosKpisComponent,
ParcelamentoKpi,
-} from './components/parcelamentos-kpis/parcelamentos-kpis';
+} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis';
import {
ParcelamentosFiltersComponent,
ParcelamentosFiltersModel,
FilterChip,
-} from './components/parcelamentos-filters/parcelamentos-filters';
+} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters';
import {
ParcelamentosTableComponent,
ParcelamentoSegment,
ParcelamentoViewItem,
-} from './components/parcelamentos-table/parcelamentos-table';
+} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table';
import {
- ParcelamentoCreateModalComponent,
ParcelamentoCreateModel,
-} from './components/parcelamento-create-modal/parcelamento-create-modal';
+} from '../../components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+import { normalizeAccentInsensitive } from '../../utils/text-normalization.util';
type MonthOption = { value: number; label: string };
type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados';
type AnnualMonthValue = { month: number; label: string; value: number | null };
type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] };
+type ParcelamentoExportRow = ParcelamentoViewItem & Partial;
@Component({
selector: 'app-parcelamentos',
@@ -46,17 +56,23 @@ type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] };
imports: [
CommonModule,
FormsModule,
+ ParcelamentosModalsComponent,
ParcelamentosKpisComponent,
ParcelamentosFiltersComponent,
ParcelamentosTableComponent,
- ParcelamentoCreateModalComponent,
],
templateUrl: './parcelamentos.html',
styleUrls: ['./parcelamentos.scss'],
})
export class Parcelamentos implements OnInit, OnDestroy {
+ readonly vm = this;
loading = false;
+ exporting = false;
errorMessage = '';
+ toastOpen = false;
+ toastMessage = '';
+ toastType: 'success' | 'danger' = 'success';
+ private toastTimer: ReturnType | null = null;
debugMode = !environment.production;
@@ -88,6 +104,12 @@ export class Parcelamentos implements OnInit, OnDestroy {
activeChips: FilterChip[] = [];
isSysAdmin = false;
+ isGestor = false;
+ isFinanceiro = false;
+
+ get canManageRecords(): boolean {
+ return this.isSysAdmin || this.isGestor;
+ }
detailOpen = false;
detailLoading = false;
@@ -137,7 +159,8 @@ export class Parcelamentos implements OnInit, OnDestroy {
constructor(
private parcelamentosService: ParcelamentosService,
- private authService: AuthService
+ private authService: AuthService,
+ private tableExportService: TableExportService
) {}
ngOnInit(): void {
@@ -147,6 +170,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.cancelDetailRequest();
+ if (this.toastTimer) clearTimeout(this.toastTimer);
}
@HostListener('document:keydown.escape')
@@ -159,30 +183,24 @@ export class Parcelamentos implements OnInit, OnDestroy {
private syncPermissions(): void {
this.isSysAdmin = this.authService.hasRole('sysadmin');
+ this.isGestor = this.authService.hasRole('gestor');
+ this.isFinanceiro = this.authService.hasRole('financeiro');
}
get totalPages(): number {
- return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
+ return computeTotalPages(this.total || 0, this.pageSize || 10);
}
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;
+ return buildPageNumbers(this.page, this.totalPages);
}
get pageStart(): number {
- return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
+ return computePageStart(this.total || 0, this.page, this.pageSize);
}
get pageEnd(): number {
- return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total);
+ return computePageEnd(this.total || 0, this.page, this.pageSize);
}
get competenciaInvalid(): boolean {
@@ -273,6 +291,50 @@ export class Parcelamentos implements OnInit, OnDestroy {
this.load();
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const baseRows = await this.fetchAllItemsForExport();
+ const rows = await this.fetchDetailedItemsForExport(baseRows);
+ if (!rows.length) {
+ this.showToast('Nenhum registro encontrado para exportar.', 'danger');
+ return;
+ }
+
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `parcelamentos_${this.activeSegment}_${timestamp}`,
+ sheetName: 'Parcelamentos',
+ rows,
+ columns: [
+ { header: 'ID', value: (row) => row.id ?? '' },
+ { header: 'Ano Ref', type: 'number', value: (row) => this.toNumber(row.anoRef) ?? 0 },
+ { header: 'Item', type: 'number', value: (row) => this.toNumber(row.item) ?? 0 },
+ { header: 'Linha', value: (row) => row.linha ?? '' },
+ { header: 'Cliente', value: (row) => row.cliente ?? '' },
+ { header: 'Status', value: (row) => row.statusLabel },
+ { header: 'Parcela Atual', type: 'number', value: (row) => this.toNumber(row.parcelaAtual) ?? 0 },
+ { header: 'Total Parcelas', type: 'number', value: (row) => this.toNumber(row.totalParcelas) ?? 0 },
+ { header: 'Qt Parcelas', value: (row) => row.qtParcelas ?? '' },
+ { header: 'Valor Cheio', type: 'currency', value: (row) => this.toNumber(row.valorCheio) ?? 0 },
+ { header: 'Desconto', type: 'currency', value: (row) => this.toNumber(row.desconto) ?? 0 },
+ { header: 'Valor c/ Desconto', type: 'currency', value: (row) => this.toNumber(row.valorComDesconto) ?? 0 },
+ { header: 'Valor Parcela', type: 'currency', value: (row) => this.toNumber(row.valorParcela) ?? 0 },
+ { header: 'Parcelas Mensais', value: (row) => this.stringifyParcelasMensais(row.parcelasMensais) },
+ { header: 'Detalhamento Anual', value: (row) => this.stringifyAnnualRows(row.annualRows) },
+ ],
+ });
+
+ this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
+ } catch {
+ this.showToast('Erro ao exportar planilha.', 'danger');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
onPageSizeChange(size: number): void {
this.pageSize = size;
this.page = 1;
@@ -280,7 +342,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
goToPage(p: number): void {
- this.page = Math.max(1, Math.min(this.totalPages, p));
+ this.page = clampPage(p, this.totalPages);
this.load();
}
@@ -356,6 +418,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
openCreateModal(): void {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
+ return;
+ }
+
this.createModel = this.buildCreateModel();
this.createError = '';
this.createOpen = true;
@@ -368,6 +435,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
saveNewParcelamento(model: ParcelamentoCreateModel): void {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
+ return;
+ }
+
if (this.createSaving) return;
this.createSaving = true;
this.createError = '';
@@ -386,6 +458,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
openEdit(item: ParcelamentoListItem): void {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
+ return;
+ }
+
const id = this.getItemId(item);
if (!id) return;
this.editOpen = true;
@@ -421,6 +498,11 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
saveEditParcelamento(model: ParcelamentoCreateModel): void {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger');
+ return;
+ }
+
if (this.editSaving || !this.editModel || !this.editId) return;
this.editSaving = true;
this.editError = '';
@@ -669,7 +751,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
}
private applySearch(list: ParcelamentoViewItem[], term: string): ParcelamentoViewItem[] {
- const search = this.normalizeText(term);
+ const search = normalizeAccentInsensitive(term);
if (!search) return list;
return list.filter((item) => {
const payload = [
@@ -681,10 +763,129 @@ export class Parcelamentos implements OnInit, OnDestroy {
]
.map((v) => (v ?? '').toString())
.join(' ');
- return this.normalizeText(payload).includes(search);
+ return normalizeAccentInsensitive(payload).includes(search);
});
}
+ private async fetchAllItemsForExport(): Promise {
+ const anoRef = this.parseNumber(this.filters.anoRef);
+ const competenciaAno = this.parseNumber(this.filters.competenciaAno);
+ const competenciaMes = this.parseNumber(this.filters.competenciaMes);
+ const sendCompetencia = competenciaAno !== null && competenciaMes !== null;
+
+ const pageSize = 500;
+ let page = 1;
+ let expectedTotal = 0;
+ const allItems: ParcelamentoListItem[] = [];
+
+ while (page <= 500) {
+ const response = await firstValueFrom(
+ this.parcelamentosService.list({
+ anoRef: anoRef ?? undefined,
+ linha: this.filters.linha?.trim() || undefined,
+ cliente: this.filters.cliente?.trim() || undefined,
+ competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined,
+ competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined,
+ page,
+ pageSize,
+ })
+ );
+
+ const normalized = this.normalizeListResponse(response);
+ allItems.push(...normalized.items);
+ expectedTotal = normalized.total;
+
+ if (normalized.items.length === 0) break;
+ if (normalized.items.length < pageSize) break;
+ if (expectedTotal > 0 && allItems.length >= expectedTotal) break;
+ page += 1;
+ }
+
+ const base = allItems.map((item) => this.toViewItem(item));
+ const searched = this.applySearch(base, this.filters.search);
+ return this.activeSegment === 'todos'
+ ? searched
+ : searched.filter((item) => item.status === this.activeSegment);
+ }
+
+ private async fetchDetailedItemsForExport(rows: ParcelamentoViewItem[]): Promise {
+ if (!rows.length) return [];
+
+ const detailedRows: ParcelamentoExportRow[] = [];
+ const chunkSize = 10;
+
+ for (let i = 0; i < rows.length; i += chunkSize) {
+ const chunk = rows.slice(i, i + chunkSize);
+ const resolved = await Promise.all(
+ chunk.map(async (row) => {
+ const id = this.getItemId(row);
+ if (!id) return row;
+ try {
+ const detailRes = await firstValueFrom(this.parcelamentosService.getById(id));
+ const detail = this.normalizeDetail(detailRes);
+ return {
+ ...row,
+ ...detail,
+ };
+ } catch {
+ return row;
+ }
+ })
+ );
+ detailedRows.push(...resolved);
+ }
+
+ return detailedRows;
+ }
+
+ private stringifyParcelasMensais(parcelas?: ParcelamentoParcela[] | null): string {
+ if (!parcelas?.length) return '';
+ return parcelas
+ .map((parcela) => {
+ const competencia = (parcela.competencia ?? '').toString().trim();
+ const valor = this.toNumber(parcela.valor);
+ const valorFmt = valor === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor);
+ return `${competencia || '-'}: ${valorFmt}`;
+ })
+ .join(' | ');
+ }
+
+ private stringifyAnnualRows(rows?: ParcelamentoAnnualRow[] | null): string {
+ if (!rows?.length) return '';
+ return rows
+ .map((row) => {
+ const year = this.parseNumber(row.year);
+ const total = this.toNumber(row.total);
+ const totalFmt = total === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(total);
+
+ const months = (row.months ?? [])
+ .map((month) => {
+ const monthNum = this.parseNumber(month.month);
+ const monthValue = this.toNumber(month.valor);
+ const monthLabel = monthNum ? String(monthNum).padStart(2, '0') : '--';
+ const monthFmt = monthValue === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(monthValue);
+ return `${monthLabel}:${monthFmt}`;
+ })
+ .join(', ');
+
+ return `${year ?? '----'} (Total ${totalFmt})${months ? ` [${months}]` : ''}`;
+ })
+ .join(' | ');
+ }
+
+ private normalizeListResponse(response: any): { items: ParcelamentoListItem[]; total: number } {
+ const anyRes: any = response ?? {};
+ const items = Array.isArray(anyRes.items)
+ ? anyRes.items.filter(Boolean)
+ : Array.isArray(anyRes.Items)
+ ? anyRes.Items.filter(Boolean)
+ : [];
+ const total = typeof anyRes.total === 'number'
+ ? anyRes.total
+ : (typeof anyRes.Total === 'number' ? anyRes.Total : 0);
+ return { items, total };
+ }
+
private resolveStatus(item: ParcelamentoListItem): ParcelamentoStatus {
const total = this.toNumber(item.totalParcelas);
const atual = this.toNumber(item.parcelaAtual);
@@ -982,13 +1183,12 @@ export class Parcelamentos implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
- private normalizeText(value: any): string {
- return (value ?? '')
- .toString()
- .trim()
- .toUpperCase()
- .normalize('NFD')
- .replace(/[\u0300-\u036f]/g, '');
+ private showToast(message: string, type: 'success' | 'danger'): void {
+ this.toastMessage = message;
+ this.toastType = type;
+ this.toastOpen = true;
+ if (this.toastTimer) clearTimeout(this.toastTimer);
+ this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000);
}
private onlyDigits(value: string): string {
diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html
index 6edb6da..24b7125 100644
--- a/src/app/pages/resumo/resumo.html
+++ b/src/app/pages/resumo/resumo.html
@@ -1,4 +1,14 @@
+
+
@@ -118,9 +128,9 @@
{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}
-
-
- Exportar
+
+
+ {{ isExporting('macrophony-planos') ? 'Exportando...' : 'Exportar' }}
@@ -437,9 +447,9 @@
{{ group.compact ? 'Expandir' : 'Compactar' }}
-
-
- Exportar
+
+
+ {{ isExporting(file) ? 'Exportando...' : 'Exportar' }}
diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss
index 80a7539..2f3f3f6 100644
--- a/src/app/pages/resumo/resumo.scss
+++ b/src/app/pages/resumo/resumo.scss
@@ -190,6 +190,19 @@
font-size: 12px;
}
+.btn-export-glass {
+ color: var(--blue);
+ border-color: rgba(3, 15, 170, 0.22);
+ background: rgba(255, 255, 255, 0.72);
+ font-weight: 800;
+}
+
+.btn-export-glass:hover:not(:disabled) {
+ border-color: var(--brand);
+ color: var(--brand);
+ background: #fff;
+}
+
.btn-icon {
width: 32px;
height: 32px;
diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts
index 3a7706d..7efaf4e 100644
--- a/src/app/pages/resumo/resumo.ts
+++ b/src/app/pages/resumo/resumo.ts
@@ -31,7 +31,17 @@ import {
ReservaPorDdd,
ReservaTotal
} from '../../services/resumo.service';
+import { TableExportService, type ExportCellType } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+import { normalizeAccentInsensitive } from '../../utils/text-normalization.util';
+import { buildApiBaseUrl } from '../../utils/api-base.util';
type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva';
@@ -85,6 +95,11 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
loading = false;
errorMessage = '';
+ toastOpen = false;
+ toastMessage = '';
+ toastType: 'success' | 'danger' = 'success';
+ private toastTimer: ReturnType | null = null;
+ private exportingKeys = new Set();
resumo: ResumoResponse | null = null;
activeTab: ResumoTab = 'planos';
@@ -139,10 +154,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
private resumoService: ResumoService,
private route: ActivatedRoute,
private router: Router,
- private cdr: ChangeDetectorRef
+ private cdr: ChangeDetectorRef,
+ private tableExportService: TableExportService
) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
this.initTables();
this.initGroupTables();
// Default chart configuration for Enterprise look
@@ -172,6 +187,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
Object.values(this.charts).forEach(c => c?.destroy());
+ if (this.toastTimer) clearTimeout(this.toastTimer);
}
setTab(tab: ResumoTab): void {
@@ -636,7 +652,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
toggleMacrophonyGroup(key: string) { if (this.macrophonyOpen.has(key)) this.macrophonyOpen.delete(key); else this.macrophonyOpen.add(key); }
openMacrophonyDetail(g: MacrophonyGroup) { this.macrophonyDetailGroup = g; this.macrophonyDetailOpen = true; }
closeMacrophonyDetail() { this.macrophonyDetailOpen = false; this.macrophonyDetailGroup = null; }
- goToMacrophonyPage(p: number) { this.macrophonyPage = p; this.updateMacrophonyView(); }
+ goToMacrophonyPage(p: number) { this.macrophonyPage = clampPage(p, this.macrophonyTotalPages); this.updateMacrophonyView(); }
onGroupedSearch(g: GroupedTableState, value?: string) {
if (typeof value === 'string') g.search = value;
@@ -644,25 +660,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
this.updateGroupView(g);
}
toggleGroupedCompact(g: GroupedTableState) { g.compact = !g.compact; }
- exportGroupedCsv(g: GroupedTableState, file: string) { this.exportCsv(g.table, file); }
+ exportGroupedCsv(g: GroupedTableState, file: string) { void this.exportTableAsXlsx(g.table, file); }
isGroupedOpen(g: GroupedTableState, key: string) { return g.open.has(key); }
toggleGroupedOpen(g: GroupedTableState, key: string) { if (g.open.has(key)) g.open.delete(key); else g.open.add(key); }
openGroupedDetail(g: GroupedTableState, item: GroupItem) { g.detailGroup = item; g.detailOpen = true; }
closeGroupedDetail(g: GroupedTableState) { g.detailOpen = false; g.detailGroup = null; }
- getGroupedPageStart(g: GroupedTableState) { return g.filtered.length ? ((g.page - 1) * g.pageSize + 1) : 0; }
- getGroupedPageEnd(g: GroupedTableState) { return g.filtered.length ? Math.min(g.page * g.pageSize, g.filtered.length) : 0; }
+ getGroupedPageStart(g: GroupedTableState) { return computePageStart(g.filtered.length, g.page, g.pageSize); }
+ getGroupedPageEnd(g: GroupedTableState) { return computePageEnd(g.filtered.length, g.page, g.pageSize); }
getGroupedPageNumbers(g: GroupedTableState) {
- const total = this.getGroupedTotalPages(g);
- if (total <= 1) return [1];
- const current = Math.min(Math.max(g.page, 1), total);
- const start = Math.max(1, current - 2);
- const end = Math.min(total, start + 4);
- const adjustedStart = Math.max(1, end - 4);
- return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i);
+ return buildPageNumbers(g.page, this.getGroupedTotalPages(g));
}
- getGroupedTotalPages(g: GroupedTableState) { return Math.max(1, Math.ceil(g.filtered.length / g.pageSize)); }
+ getGroupedTotalPages(g: GroupedTableState) { return computeTotalPages(g.filtered.length, g.pageSize); }
goToGroupedPage(g: GroupedTableState, p: number) {
- g.page = Math.min(this.getGroupedTotalPages(g), Math.max(1, p));
+ g.page = clampPage(p, this.getGroupedTotalPages(g));
this.updateGroupView(g);
}
getTableRowClass(_: TableState, __: T) { return false; }
@@ -677,6 +687,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return normalized === 'true' || normalized === '1' || normalized === 'sim';
}
+ isExporting(key: string): boolean {
+ return this.exportingKeys.has(key);
+ }
+
private initTables() {
const hideMoneyColumns = (cols: TableColumn[]) =>
this.showFinancial ? cols : cols.filter((c) => c.type !== 'money');
@@ -1045,12 +1059,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
});
this.macrophonyGroups = groups;
- const search = this.normalizeText(this.macrophonySearch);
+ const search = normalizeAccentInsensitive(this.macrophonySearch);
this.macrophonyFiltered = !search
? groups
: groups.filter((group) =>
- this.normalizeText(group.plano).includes(search) ||
- this.normalizeText(group.gbLabel).includes(search)
+ normalizeAccentInsensitive(group.plano).includes(search) ||
+ normalizeAccentInsensitive(group.gbLabel).includes(search)
);
const totalPages = Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize));
@@ -1086,12 +1100,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
if (group.groupSort) groups.sort(group.groupSort);
group.groups = groups;
- const search = this.normalizeText(group.search);
+ const search = normalizeAccentInsensitive(group.search);
group.filtered = !search
? groups
: groups.filter((g) =>
- this.normalizeText(g.title).includes(search) ||
- this.normalizeText(g.subtitle).includes(search)
+ normalizeAccentInsensitive(g.title).includes(search) ||
+ normalizeAccentInsensitive(g.subtitle).includes(search)
);
const totalPages = Math.max(1, Math.ceil(group.filtered.length / group.pageSize));
@@ -1106,15 +1120,6 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
}
}
- private normalizeText(value: any): string {
- return (value ?? '')
- .toString()
- .trim()
- .toUpperCase()
- .normalize('NFD')
- .replace(/[\u0300-\u036f]/g, '');
- }
-
private sumGroup(rows: T[], getter: (row: T) => any): number {
return rows.reduce((acc, row) => acc + (this.toNumber(getter(row)) ?? 0), 0);
}
@@ -1133,7 +1138,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
rows.forEach((row) => {
const planoContrato = (row.planoContrato ?? '-').toString().trim() || '-';
- const key = this.normalizeText(planoContrato);
+ const key = normalizeAccentInsensitive(planoContrato);
const gb =
this.extractGbFromPlanName(planoContrato) ??
this.toNumber(row.gb ?? row.franquiaGb);
@@ -1214,78 +1219,59 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Number.isNaN(parsed) ? null : parsed;
}
- private exportCsv(table: TableState, filename: string) {
+ private async exportTableAsXlsx(table: TableState, fileKey: string): Promise {
if (!isPlatformBrowser(this.platformId)) return;
+ if (this.exportingKeys.has(fileKey)) return;
+
const rows = table.data ?? [];
- const columns = table.columns ?? [];
- const generatedAt = new Date().toLocaleString('pt-BR');
- const escapeHtml = (value: string) =>
- value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+ if (!rows.length) {
+ this.showToast('Nenhum registro encontrado para exportar.', 'danger');
+ return;
+ }
- const headerHtml = columns
- .map((column) => `${escapeHtml(column.label)} `)
- .join('');
+ this.exportingKeys.add(fileKey);
+ try {
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `${fileKey}_${timestamp}`,
+ sheetName: table.label || 'Resumo',
+ rows,
+ columns: (table.columns ?? []).map((column) => ({
+ header: column.label,
+ type: this.mapColumnType(column.type),
+ value: (row: T) => this.getExportColumnValue(column, row),
+ })),
+ });
- const bodyHtml = rows
- .map((row, index) => {
- const cells = columns
- .map((column) => {
- const value = this.formatCell(column, row);
- const toneClass = column.tone ? this.getToneClass(column.value(row)) : '';
- const alignClass = column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : '';
- const classes = [alignClass, toneClass].filter(Boolean).join(' ');
- return `${escapeHtml(String(value))} `;
- })
- .join('');
- return `${cells} `;
- })
- .join('');
+ this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
+ } catch {
+ this.showToast('Erro ao exportar planilha.', 'danger');
+ } finally {
+ this.exportingKeys.delete(fileKey);
+ }
+ }
- const html = `
-
-
-
-
-
-
- ${escapeHtml(table.label || 'Resumo')}
- Exportado em ${escapeHtml(generatedAt)} | Total de linhas: ${rows.length}
-
-
- ${headerHtml}
-
-
- ${bodyHtml}
-
-
-
-`;
+ private getExportColumnValue(column: TableColumn, row: T): unknown {
+ const rawValue = column.value(row);
+ if (column.type === 'money' || column.type === 'number' || column.type === 'gb') {
+ const numeric = this.toNumber(rawValue);
+ if (numeric !== null) return numeric;
+ }
+ return this.formatCell(column, row);
+ }
- const blob = new Blob([`\uFEFF${html}`], { type: 'application/vnd.ms-excel;charset=utf-8;' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${filename}.xls`;
- a.click();
- URL.revokeObjectURL(url);
+ private mapColumnType(type: TableColumn['type']): ExportCellType {
+ if (type === 'money') return 'currency';
+ if (type === 'number' || type === 'gb') return 'number';
+ return 'text';
+ }
+
+ private showToast(message: string, type: 'success' | 'danger'): void {
+ this.toastMessage = message;
+ this.toastType = type;
+ this.toastOpen = true;
+ if (this.toastTimer) clearTimeout(this.toastTimer);
+ this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000);
}
private getReservaPorDddChartData(): Array<{ label: string; totalLinhas: number }> {
@@ -1310,7 +1296,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Array.from(map.entries()).map(([label, totalLinhas]) => ({ label, totalLinhas }));
}
- exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); }
+ exportMacrophonyCsv() { void this.exportTableAsXlsx(this.tableMacrophony, 'macrophony-planos'); }
findLineTotal(k: string[]): LineTotal | null {
const keys = k.map((item) => item.toUpperCase());
const list = this.getEffectiveLineTotais();
@@ -1335,19 +1321,13 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy {
return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : [];
}
- get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; }
- get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); }
+ get macrophonyPageStart() { return computePageStart(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); }
+ get macrophonyPageEnd() { return computePageEnd(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); }
get macrophonyFilteredGroups() { return this.macrophonyFiltered; }
get macrophonyPageNumbers() {
- const total = this.macrophonyTotalPages;
- if (total <= 1) return [1];
- const current = Math.min(Math.max(this.macrophonyPage, 1), total);
- const start = Math.max(1, current - 2);
- const end = Math.min(total, start + 4);
- const adjustedStart = Math.max(1, end - 4);
- return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i);
+ return buildPageNumbers(this.macrophonyPage, this.macrophonyTotalPages);
}
- get macrophonyTotalPages() { return Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); }
+ get macrophonyTotalPages() { return computeTotalPages(this.macrophonyFiltered.length, this.macrophonyPageSize); }
get planosTotals() { return this.resumo?.macrophonyTotals; }
get contratosTotals() { return this.resumo?.planoContratoTotal; }
get clientesTotals() { return this.resumo?.vivoLineTotals; }
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..978f34a
--- /dev/null
+++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts
@@ -0,0 +1,186 @@
+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';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+
+@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 = clampPage(pageNumber, this.totalPages);
+ this.fetch();
+ }
+
+ 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);
+ }
+
+ 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/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html
index 6e31ffb..c8fa43f 100644
--- a/src/app/pages/troca-numero/troca-numero.html
+++ b/src/app/pages/troca-numero/troca-numero.html
@@ -31,7 +31,11 @@
@@ -86,7 +90,6 @@
Itens por pág:
@@ -153,7 +156,7 @@
{{ r.observacao || '-' }}
-
+
@@ -194,184 +197,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preparando edição...
-
-
-
-
-
-
-
-
-
+
diff --git a/src/app/pages/troca-numero/troca-numero.scss b/src/app/pages/troca-numero/troca-numero.scss
index 8c6fa2e..4e94435 100644
--- a/src/app/pages/troca-numero/troca-numero.scss
+++ b/src/app/pages/troca-numero/troca-numero.scss
@@ -534,9 +534,6 @@
}
/* MODALS */
-.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
-.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
-
.modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.8);
diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts
index b27b637..7264919 100644
--- a/src/app/pages/troca-numero/troca-numero.ts
+++ b/src/app/pages/troca-numero/troca-numero.ts
@@ -10,8 +10,20 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { AuthService } from '../../services/auth.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
+import { TableExportService } from '../../services/table-export.service';
import { environment } from '../../../environments/environment';
+import { TrocaNumeroModalsComponent } from '../../components/page-modals/troca-numero-modals/troca-numero-modals';
+import {
+ buildPageNumbers,
+ clampPage,
+ computePageEnd,
+ computePageStart,
+ computeTotalPages
+} from '../../utils/pagination.util';
+import { buildApiEndpoint } from '../../utils/api-base.util';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
@@ -56,34 +68,30 @@ interface LineOptionDto {
@Component({
standalone: true,
- imports: [CommonModule, FormsModule, CustomSelectComponent],
+ imports: [CommonModule, FormsModule, CustomSelectComponent, TrocaNumeroModalsComponent],
templateUrl: './troca-numero.html',
styleUrls: ['./troca-numero.scss']
})
export class TrocaNumero implements AfterViewInit {
+ readonly vm = this;
toastMessage = '';
loading = false;
+ exporting = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
- private cdr: ChangeDetectorRef
+ private cdr: ChangeDetectorRef,
+ private authService: AuthService,
+ private tableExportService: TableExportService
) {}
- private readonly apiBase = (() => {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
- return `${apiBase}/trocanumero`;
- })();
+ private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'trocanumero');
/** ✅ base do GERAL (para buscar clientes/linhas no modal) */
- private readonly linesApiBase = (() => {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
- return `${apiBase}/lines`;
- })();
+ private readonly linesApiBase = buildApiEndpoint(environment.apiUrl, 'lines');
// ====== DATA ======
groups: GroupItem[] = [];
@@ -132,9 +140,20 @@ export class TrocaNumero implements AfterViewInit {
loadingClients = false;
loadingLines = false;
+ isSysAdmin = false;
+ isGestor = false;
+ isFinanceiro = false;
+
+ get canManageRecords(): boolean {
+ return this.isSysAdmin || this.isGestor;
+ }
+
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
+ this.isSysAdmin = this.authService.hasRole('sysadmin');
+ this.isGestor = this.authService.hasRole('gestor');
+ this.isFinanceiro = this.authService.hasRole('financeiro');
setTimeout(() => this.refresh());
}
@@ -151,6 +170,90 @@ export class TrocaNumero implements AfterViewInit {
this.loadForGroups();
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const rows = await this.fetchAllRowsForExport();
+ if (!rows.length) {
+ await this.showToast('Nenhum registro encontrado para exportar.');
+ return;
+ }
+
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `troca_numero_${timestamp}`,
+ sheetName: 'TrocaNumero',
+ rows,
+ columns: [
+ { header: 'ID', value: (row) => row.id ?? '' },
+ { header: 'Motivo', value: (row) => row.motivo },
+ { header: 'Cliente', value: (row) => this.getRawField(row, ['cliente', 'Cliente']) ?? '' },
+ { header: 'Usuario', value: (row) => this.getRawField(row, ['usuario', 'Usuario']) ?? '' },
+ { header: 'Skil', value: (row) => this.getRawField(row, ['skil', 'Skil']) ?? '' },
+ { header: 'Item', type: 'number', value: (row) => this.toNumberOrNull(row.item) ?? 0 },
+ { header: 'Linha Antiga', value: (row) => row.linhaAntiga },
+ { header: 'Linha Nova', value: (row) => row.linhaNova },
+ { header: 'ICCID', value: (row) => row.iccid },
+ { header: 'Data da Troca', type: 'date', value: (row) => row.dataTroca },
+ { header: 'Observacao', value: (row) => row.observacao },
+ { header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') },
+ { header: 'Linha ID (Geral)', value: (row) => this.getRawField(row, ['mobileLineId', 'MobileLineId']) ?? '' },
+ { header: 'Criado Em', type: 'datetime', value: (row) => this.getRawField(row, ['createdAt', 'CreatedAt']) ?? '' },
+ { header: 'Atualizado Em', type: 'datetime', value: (row) => this.getRawField(row, ['updatedAt', 'UpdatedAt']) ?? '' },
+ ],
+ });
+
+ await this.showToast(`Planilha exportada com ${rows.length} registro(s).`);
+ } catch {
+ await this.showToast('Erro ao exportar planilha.');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private async fetchAllRowsForExport(): Promise {
+ const pageSize = 2000;
+ let page = 1;
+ let expectedTotal = 0;
+ const rows: TrocaRow[] = [];
+
+ while (page <= 500) {
+ const params = new HttpParams()
+ .set('page', String(page))
+ .set('pageSize', String(pageSize))
+ .set('search', (this.searchTerm ?? '').trim())
+ .set('sortBy', 'motivo')
+ .set('sortDir', 'asc');
+
+ const response = await firstValueFrom(
+ this.http.get | any[]>(this.apiBase, { params })
+ );
+
+ const items = Array.isArray(response) ? response : (response.items ?? []);
+ const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx));
+ rows.push(...normalized);
+ expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0);
+
+ if (Array.isArray(response)) break;
+ if (items.length === 0) break;
+ if (items.length < pageSize) break;
+ if (expectedTotal > 0 && rows.length >= expectedTotal) break;
+ page += 1;
+ }
+
+ return rows.sort((a, b) => {
+ const byMotivo = (a.motivo ?? '').localeCompare(b.motivo ?? '', 'pt-BR', { sensitivity: 'base' });
+ if (byMotivo !== 0) return byMotivo;
+
+ const byItem = (this.toNumberOrNull(a.item) ?? 0) - (this.toNumberOrNull(b.item) ?? 0);
+ if (byItem !== 0) return byItem;
+
+ return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' });
+ });
+ }
+
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
@@ -175,29 +278,19 @@ export class TrocaNumero implements AfterViewInit {
}
goToPage(p: number) {
- this.page = Math.max(1, Math.min(this.totalPages, p));
+ this.page = clampPage(p, this.totalPages);
this.applyPagination();
}
- get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
+ get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); }
get pageNumbers() {
- 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;
+ return buildPageNumbers(this.page, this.totalPages);
}
- get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
+ get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); }
get pageEnd() {
- if (this.total === 0) return 0;
- return Math.min(this.page * this.pageSize, this.total);
+ return computePageEnd(this.total || 0, this.page, this.pageSize);
}
trackById(_: number, row: TrocaRow) { return row.id; }
@@ -414,6 +507,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL EDIÇÃO ======
onEditar(r: TrocaRow) {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.editOpen = true;
this.editSaving = false;
@@ -436,6 +534,11 @@ export class TrocaNumero implements AfterViewInit {
}
saveEdit() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
if (!this.editModel || !this.editModel.id) return;
this.editSaving = true;
@@ -467,6 +570,11 @@ export class TrocaNumero implements AfterViewInit {
// ====== MODAL CRIAÇÃO ======
onCreate() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
this.createOpen = true;
this.createSaving = false;
@@ -496,6 +604,11 @@ export class TrocaNumero implements AfterViewInit {
}
saveCreate() {
+ if (!this.canManageRecords) {
+ this.showToast('Perfil Financeiro possui acesso somente leitura.');
+ return;
+ }
+
// ✅ validações do "beber do GERAL"
if (!String(this.selectedCliente ?? '').trim()) {
this.showToast('Selecione um Cliente do GERAL.');
@@ -542,6 +655,15 @@ export class TrocaNumero implements AfterViewInit {
return Number.isFinite(n) ? n : null;
}
+ private getRawField(row: TrocaRow, keys: string[]): string | null {
+ for (const key of keys) {
+ const value = row?.raw?.[key];
+ if (value === undefined || value === null || String(value).trim() === '') continue;
+ return String(value);
+ }
+ return null;
+ }
+
private isoToDateInput(iso: string | null | undefined): string {
if (!iso) return '';
const dt = new Date(iso);
diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html
index 1ae2d25..fa2b8bf 100644
--- a/src/app/pages/vigencia/vigencia.html
+++ b/src/app/pages/vigencia/vigencia.html
@@ -24,6 +24,10 @@
Controle de contratos e fidelização
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cliente
- {{ selectedRow?.cliente || '-' }}
-
-
- Linha
- {{ selectedRow?.linha || '-' }}
-
-
- Conta
- {{ selectedRow?.conta || '-' }}
-
-
- Usuário
- {{ selectedRow?.usuario || '-' }}
-
-
- Plano
- {{ selectedRow?.planoContrato || '-' }}
-
-
- Efetivação
- {{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
-
-
- Término
-
- {{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
-
-
-
- Situação
-
- {{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
-
-
-
- Renovação
-
- {{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }}
-
-
-
- Valor Total
- {{ (selectedRow?.total || 0) | currency:'BRL' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Confirma remover o registro {{ deleteTarget?.linha }} ?
-
-
-
-
-
-
+
diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss
index 5e41891..c28c00a 100644
--- a/src/app/pages/vigencia/vigencia.scss
+++ b/src/app/pages/vigencia/vigencia.scss
@@ -372,14 +372,6 @@
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
/* MODAL */
-.lg-backdrop {
- position: fixed;
- inset: 0;
- background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%);
- z-index: 9990;
- backdrop-filter: blur(5px);
-}
-.lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255,255,255,0.86);
diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts
index 31d1148..0bcd584 100644
--- a/src/app/pages/vigencia/vigencia.ts
+++ b/src/app/pages/vigencia/vigencia.ts
@@ -3,13 +3,16 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
-import { Subscription } from 'rxjs';
+import { Subscription, firstValueFrom } from 'rxjs';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
+import { TableExportService } from '../../services/table-export.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
+import { computeTotalPages } from '../../utils/pagination.util';
+import { VigenciaModalsComponent } from '../../components/page-modals/vigencia-modals/vigencia-modals';
type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger';
@@ -26,12 +29,14 @@ interface LineOptionDto {
@Component({
selector: 'app-vigencia',
standalone: true,
- imports: [CommonModule, FormsModule, CustomSelectComponent],
+ imports: [CommonModule, FormsModule, CustomSelectComponent, VigenciaModalsComponent],
templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'],
})
export class VigenciaComponent implements OnInit, OnDestroy {
+ readonly vm = this;
loading = false;
+ exporting = false;
errorMsg = '';
// Filtros
@@ -113,7 +118,8 @@ export class VigenciaComponent implements OnInit, OnDestroy {
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService,
- private route: ActivatedRoute
+ private route: ActivatedRoute,
+ private tableExportService: TableExportService
) {}
ngOnInit(): void {
@@ -157,7 +163,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
}
get totalPages(): number {
- return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
+ return computeTotalPages(this.total || 0, this.pageSize || 10);
}
fetch(goToPage?: number): void {
@@ -295,6 +301,107 @@ export class VigenciaComponent implements OnInit, OnDestroy {
this.fetch(1);
}
+ async onExport(): Promise {
+ if (this.exporting) return;
+ this.exporting = true;
+
+ try {
+ const baseRows = await this.fetchAllRowsForExport();
+ const rows = await this.fetchDetailedRowsForExport(baseRows);
+ if (!rows.length) {
+ this.showToast('Nenhum registro encontrado para exportar.', 'danger');
+ return;
+ }
+
+ const timestamp = this.tableExportService.buildTimestamp();
+ await this.tableExportService.exportAsXlsx({
+ fileName: `vigencia_${timestamp}`,
+ sheetName: 'Vigencia',
+ rows,
+ columns: [
+ { header: 'ID', value: (row) => row.id ?? '' },
+ { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 },
+ { header: 'Linha', value: (row) => row.linha ?? '' },
+ { header: 'Conta', value: (row) => row.conta ?? '' },
+ { header: 'Cliente', value: (row) => row.cliente ?? '' },
+ { header: 'Usuario', value: (row) => row.usuario ?? '' },
+ { header: 'Plano', value: (row) => row.planoContrato ?? '' },
+ { header: 'Efetivacao', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' },
+ { header: 'Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' },
+ { header: 'Status', value: (row) => (this.isVencido(row.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo') },
+ { header: 'Auto Renovacao (anos)', type: 'number', value: (row) => this.toNullableNumber(row.autoRenewYears) ?? 0 },
+ { header: 'Auto Renovacao Referencia', type: 'date', value: (row) => row.autoRenewReferenceEndDate ?? '' },
+ { header: 'Auto Renovacao Configurada Em', type: 'datetime', value: (row) => row.autoRenewConfiguredAt ?? '' },
+ { header: 'Ultima Auto Renovacao', type: 'datetime', value: (row) => row.lastAutoRenewedAt ?? '' },
+ { header: 'Total', type: 'currency', value: (row) => this.toNullableNumber(row.total) ?? 0 },
+ { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' },
+ { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' },
+ ],
+ });
+
+ this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success');
+ } catch {
+ this.showToast('Erro ao exportar planilha.', 'danger');
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private async fetchAllRowsForExport(): Promise {
+ const pageSize = 500;
+ let page = 1;
+ let expectedTotal = 0;
+ const all: VigenciaRow[] = [];
+
+ while (page <= 500) {
+ const response = await firstValueFrom(
+ this.vigenciaService.getVigencia({
+ search: this.search?.trim(),
+ client: this.client?.trim(),
+ page,
+ pageSize,
+ sortBy: 'item',
+ sortDir: 'asc',
+ })
+ );
+
+ 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 async fetchDetailedRowsForExport(rows: VigenciaRow[]): Promise {
+ if (!rows.length) return [];
+
+ const detailedRows: VigenciaRow[] = [];
+ const chunkSize = 10;
+
+ for (let i = 0; i < rows.length; i += chunkSize) {
+ const chunk = rows.slice(i, i + chunkSize);
+ const resolved = await Promise.all(
+ chunk.map(async (row) => {
+ try {
+ return await firstValueFrom(this.vigenciaService.getById(row.id));
+ } catch {
+ return row;
+ }
+ })
+ );
+
+ detailedRows.push(...resolved);
+ }
+
+ return detailedRows;
+ }
+
scheduleAutoRenew(row: VigenciaRow): void {
if (!row?.id) return;
const years = 2;
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
index 89a9b5a..bd292e3 100644
--- a/src/app/services/auth.service.ts
+++ b/src/app/services/auth.service.ts
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { BehaviorSubject } from 'rxjs';
import { tap } from 'rxjs/operators';
+import { MveAuditService } from './mve-audit.service';
export interface RegisterPayload {
name: string;
@@ -43,7 +44,10 @@ export class AuthService {
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
private readonly rememberMeHours = 6;
- constructor(private http: HttpClient) {
+ constructor(
+ private http: HttpClient,
+ private readonly mveAuditService: MveAuditService
+ ) {
this.syncUserProfileFromToken();
}
@@ -65,10 +69,12 @@ export class AuthService {
logout() {
if (typeof window === 'undefined') {
+ this.mveAuditService.clearCache();
this.userProfileSubject.next(null);
return;
}
+ this.mveAuditService.clearCache();
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
this.userProfileSubject.next(null);
@@ -76,6 +82,7 @@ export class AuthService {
setToken(token: string, rememberMe = false) {
if (typeof window === 'undefined') return;
+ this.mveAuditService.clearCache();
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
diff --git a/src/app/services/billing.ts b/src/app/services/billing.ts
index a03c960..a3070bc 100644
--- a/src/app/services/billing.ts
+++ b/src/app/services/billing.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
@@ -75,8 +76,7 @@ export class BillingService {
private readonly baseUrl: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ const apiBase = buildApiBaseUrl(environment.apiUrl);
this.baseUrl = `${apiBase}/billing`;
}
diff --git a/src/app/services/chips-controle.service.ts b/src/app/services/chips-controle.service.ts
index 16423a7..2afafb4 100644
--- a/src/app/services/chips-controle.service.ts
+++ b/src/app/services/chips-controle.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@@ -71,8 +72,7 @@ export class ChipsControleService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getChipsVirgens(opts: {
diff --git a/src/app/services/dados-usuarios.service.ts b/src/app/services/dados-usuarios.service.ts
index 0e78ddc..cd3c8d0 100644
--- a/src/app/services/dados-usuarios.service.ts
+++ b/src/app/services/dados-usuarios.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@@ -75,8 +76,7 @@ export class DadosUsuariosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getGroups(opts: {
diff --git a/src/app/services/historico.service.ts b/src/app/services/historico.service.ts
index 2fb0c5c..83fd96c 100644
--- a/src/app/services/historico.service.ts
+++ b/src/app/services/historico.service.ts
@@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type AuditChangeType = 'added' | 'modified' | 'removed';
@@ -50,13 +51,36 @@ export interface HistoricoQuery {
pageSize?: number;
}
+export interface LineHistoricoQuery {
+ 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;
+ search?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ page?: number;
+ pageSize?: number;
+}
+
@Injectable({ providedIn: 'root' })
export class HistoricoService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(params: HistoricoQuery): Observable> {
@@ -74,4 +98,36 @@ export class HistoricoService {
return this.http.get>(`${this.baseApi}/historico`, { params: httpParams });
}
+
+ listByLine(params: LineHistoricoQuery): Observable> {
+ let httpParams = new HttpParams();
+ if (params.line) httpParams = httpParams.set('line', params.line);
+ 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>(`${this.baseApi}/historico/linhas`, { params: httpParams });
+ }
+
+ listByChip(params: ChipHistoricoQuery): Observable> {
+ 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>(`${this.baseApi}/historico/chips`, { params: httpParams });
+ }
}
diff --git a/src/app/services/lines.service.ts b/src/app/services/lines.service.ts
index 022570c..5b18d45 100644
--- a/src/app/services/lines.service.ts
+++ b/src/app/services/lines.service.ts
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export interface PagedResult {
page: number;
@@ -72,8 +73,7 @@ export class LinesService {
private readonly baseUrl: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ const apiBase = buildApiBaseUrl(environment.apiUrl);
this.baseUrl = `${apiBase}/lines`;
}
diff --git a/src/app/services/mve-audit.service.ts b/src/app/services/mve-audit.service.ts
new file mode 100644
index 0000000..3bedad1
--- /dev/null
+++ b/src/app/services/mve-audit.service.ts
@@ -0,0 +1,189 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { catchError, tap } from 'rxjs/operators';
+import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
+
+export const MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY = 'linegestao.mveAudit.currentRunId';
+
+export interface MveAuditSummary {
+ totalSystemLines: number;
+ totalReportLines: number;
+ totalConciliated: number;
+ totalStatusDivergences: number;
+ totalDataDivergences: number;
+ totalOnlyInSystem: number;
+ totalOnlyInReport: number;
+ totalDuplicateReportLines: number;
+ totalDuplicateSystemLines: number;
+ totalInvalidRows: number;
+ totalUnknownStatuses: number;
+ totalSyncableIssues: number;
+ appliedIssuesCount: number;
+ appliedLinesCount: number;
+ appliedFieldsCount: number;
+}
+
+export interface MveAuditSnapshot {
+ numeroLinha?: string | null;
+ statusLinha?: string | null;
+ statusConta?: string | null;
+ planoLinha?: string | null;
+ dataAtivacao?: string | null;
+ terminoContrato?: string | null;
+ chip?: string | null;
+ conta?: string | null;
+ cnpj?: string | null;
+ modeloAparelho?: string | null;
+ fabricante?: string | null;
+ servicosAtivos?: string[];
+}
+
+export interface MveAuditDifference {
+ fieldKey: string;
+ label: string;
+ systemValue?: string | null;
+ reportValue?: string | null;
+ syncable: boolean;
+}
+
+export interface MveAuditIssue {
+ id: string;
+ sourceRowNumber?: number | null;
+ numeroLinha: string;
+ mobileLineId?: string | null;
+ systemItem?: number | null;
+ issueType: string;
+ situation: string;
+ severity: string;
+ syncable: boolean;
+ applied: boolean;
+ actionSuggestion?: string | null;
+ notes?: string | null;
+ systemStatus?: string | null;
+ reportStatus?: string | null;
+ systemPlan?: string | null;
+ reportPlan?: string | null;
+ systemSnapshot?: MveAuditSnapshot | null;
+ reportSnapshot?: MveAuditSnapshot | null;
+ differences: MveAuditDifference[];
+}
+
+export interface MveAuditRun {
+ id: string;
+ fileName?: string | null;
+ fileEncoding?: string | null;
+ status: string;
+ importedAtUtc: string;
+ appliedAtUtc?: string | null;
+ appliedByUserName?: string | null;
+ appliedByUserEmail?: string | null;
+ summary: MveAuditSummary;
+ issues: MveAuditIssue[];
+}
+
+export interface ApplyMveAuditResult {
+ auditRunId: string;
+ requestedIssues: number;
+ appliedIssues: number;
+ updatedLines: number;
+ updatedFields: number;
+ skippedIssues: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class MveAuditService {
+ private readonly baseUrl: string;
+ private currentRun: MveAuditRun | null = null;
+
+ constructor(private readonly http: HttpClient) {
+ const apiBase = buildApiBaseUrl(environment.apiUrl);
+ this.baseUrl = `${apiBase}/mve-audit`;
+ }
+
+ preview(file: File): Observable {
+ const form = new FormData();
+ form.append('file', file);
+ return this.http
+ .post(`${this.baseUrl}/preview`, form)
+ .pipe(tap((run) => this.cacheRun(run)));
+ }
+
+ getById(id: string): Observable {
+ return this.http
+ .get(`${this.baseUrl}/${id}`)
+ .pipe(tap((run) => this.cacheRun(run)));
+ }
+
+ getLatest(): Observable {
+ return this.http
+ .get(`${this.baseUrl}/latest`)
+ .pipe(tap((run) => this.cacheRun(run)));
+ }
+
+ apply(runId: string, issueIds?: string[]): Observable {
+ return this.http.post(`${this.baseUrl}/${runId}/apply`, {
+ issueIds: issueIds && issueIds.length > 0 ? issueIds : null,
+ });
+ }
+
+ getCachedRun(): MveAuditRun | null {
+ return this.currentRun;
+ }
+
+ getCachedRunId(): string | null {
+ if (this.currentRun?.id) {
+ return this.currentRun.id;
+ }
+
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ return sessionStorage.getItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY);
+ }
+
+ restoreCachedRun(): Observable {
+ if (this.currentRun) {
+ return of(this.currentRun);
+ }
+
+ const runId = this.getCachedRunId();
+ if (!runId) {
+ return of(null);
+ }
+
+ return this.getById(runId).pipe(
+ catchError(() => {
+ this.clearCache();
+ return of(null);
+ })
+ );
+ }
+
+ clearCache(): void {
+ this.currentRun = null;
+
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ sessionStorage.removeItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY);
+ }
+
+ private cacheRun(run: MveAuditRun | null | undefined): void {
+ if (!run?.id) {
+ this.clearCache();
+ return;
+ }
+
+ this.currentRun = run;
+
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ sessionStorage.setItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY, run.id);
+ }
+}
diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts
index b78119d..6db4393 100644
--- a/src/app/services/notifications.service.ts
+++ b/src/app/services/notifications.service.ts
@@ -3,6 +3,7 @@ import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
@@ -40,8 +41,7 @@ export class NotificationsService {
readonly events$ = this.eventsSubject.asObservable();
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(): Observable {
diff --git a/src/app/services/parcelamentos.service.ts b/src/app/services/parcelamentos.service.ts
index 1a3a9b2..484c034 100644
--- a/src/app/services/parcelamentos.service.ts
+++ b/src/app/services/parcelamentos.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export interface PagedResult {
page: number;
@@ -76,8 +77,7 @@ export class ParcelamentosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
list(filters: {
diff --git a/src/app/services/profile.service.ts b/src/app/services/profile.service.ts
index 3879844..f6ec137 100644
--- a/src/app/services/profile.service.ts
+++ b/src/app/services/profile.service.ts
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type ProfileMeDto = {
id: string;
@@ -26,8 +27,7 @@ export class ProfileService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getMe(): Observable {
diff --git a/src/app/services/resumo.service.ts b/src/app/services/resumo.service.ts
index 507872f..011dd6e 100644
--- a/src/app/services/resumo.service.ts
+++ b/src/app/services/resumo.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export interface MacrophonyPlan {
planoContrato?: string | null;
@@ -119,8 +120,7 @@ export class ResumoService {
private readonly apiBase: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.apiBase = buildApiBaseUrl(environment.apiUrl);
}
getResumo() {
diff --git a/src/app/services/solicitacoes-linhas.service.ts b/src/app/services/solicitacoes-linhas.service.ts
new file mode 100644
index 0000000..228e5de
--- /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';
+import { buildApiBaseUrl } from '../utils/api-base.util';
+
+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 apiBase = buildApiBaseUrl(environment.apiUrl);
+ 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);
+ }
+}
diff --git a/src/app/services/sysadmin.service.ts b/src/app/services/sysadmin.service.ts
index b59eefa..4b01ab6 100644
--- a/src/app/services/sysadmin.service.ts
+++ b/src/app/services/sysadmin.service.ts
@@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type SystemTenantDto = {
tenantId: string;
@@ -34,8 +35,7 @@ export class SysadminService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
listTenants(params?: ListSystemTenantsParams): Observable {
diff --git a/src/app/services/table-export.service.ts b/src/app/services/table-export.service.ts
new file mode 100644
index 0000000..8c9f72d
--- /dev/null
+++ b/src/app/services/table-export.service.ts
@@ -0,0 +1,462 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
+
+export type ExportCellType = 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean';
+
+export interface TableExportColumn {
+ header: string;
+ key?: string;
+ type?: ExportCellType;
+ width?: number;
+ value: (row: T, index: number) => unknown;
+}
+
+export interface TableExportRequest {
+ fileName: string;
+ sheetName?: string;
+ columns: TableExportColumn[];
+ rows: T[];
+ templateBuffer?: ArrayBuffer | null;
+}
+
+type CellStyleSnapshot = {
+ font?: Partial;
+ fill?: import('exceljs').Fill;
+ border?: Partial;
+ alignment?: Partial;
+};
+
+type TemplateStyleSnapshot = {
+ headerStyles: CellStyleSnapshot[];
+ bodyStyle?: CellStyleSnapshot;
+ bodyAltStyle?: CellStyleSnapshot;
+ columnWidths: Array;
+};
+
+@Injectable({ providedIn: 'root' })
+export class TableExportService {
+ private readonly templatesApiBase = (() => {
+ const apiBase = buildApiBaseUrl(environment.apiUrl);
+ return `${apiBase}/templates`;
+ })();
+ private defaultTemplateBufferPromise: Promise | null = null;
+ private cachedDefaultTemplateStyle?: TemplateStyleSnapshot;
+
+ constructor(private readonly http: HttpClient) {}
+
+ async exportAsXlsx(request: TableExportRequest): Promise {
+ const ExcelJS = await import('exceljs');
+ const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer());
+ const templateStyle = await this.resolveTemplateStyle(ExcelJS, templateBuffer);
+ const workbook = new ExcelJS.Workbook();
+ const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados'));
+
+ const rawColumns = request.columns ?? [];
+ const columns = rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header));
+ const rows = request.rows ?? [];
+
+ if (!columns.length) {
+ throw new Error('Nenhuma coluna exportavel apos remover ITEM/ID.');
+ }
+
+ const headerValues = columns.map((c) => c.header ?? '');
+ sheet.addRow(headerValues);
+
+ rows.forEach((row, rowIndex) => {
+ const values = columns.map((column) => this.normalizeValue(column.value(row, rowIndex), column.type));
+ sheet.addRow(values);
+ });
+
+ this.applyHeaderStyle(sheet, columns.length, templateStyle);
+ this.applyBodyStyle(sheet, columns, rows.length, templateStyle);
+ this.applyColumnWidths(sheet, columns, rows, templateStyle);
+ this.applyAutoFilter(sheet, columns.length);
+ sheet.views = [{ state: 'frozen', ySplit: 1 }];
+
+ const extensionSafeName = this.ensureXlsxExtension(request.fileName);
+ const buffer = await workbook.xlsx.writeBuffer();
+ this.downloadBuffer(buffer, extensionSafeName);
+ }
+
+ buildTimestamp(date: Date = new Date()): string {
+ const year = date.getFullYear();
+ const month = this.pad2(date.getMonth() + 1);
+ const day = this.pad2(date.getDate());
+ const hour = this.pad2(date.getHours());
+ const minute = this.pad2(date.getMinutes());
+ return `${year}-${month}-${day}_${hour}-${minute}`;
+ }
+
+ private applyHeaderStyle(
+ sheet: import('exceljs').Worksheet,
+ columnCount: number,
+ templateStyle?: TemplateStyleSnapshot,
+ ): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.height = 24;
+
+ for (let col = 1; col <= columnCount; col += 1) {
+ const cell = headerRow.getCell(col);
+ const templateCell = this.getTemplateStyleByIndex(templateStyle, col - 1);
+ cell.font = this.cloneStyle(templateCell?.font) || { bold: true, color: { argb: 'FFFFFFFF' }, name: 'Calibri', size: 11 };
+ cell.fill = this.cloneStyle(templateCell?.fill) || {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FF0A58CA' },
+ };
+ cell.alignment = this.cloneStyle(templateCell?.alignment) || { vertical: 'middle', horizontal: 'center', wrapText: true };
+ cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder();
+ }
+ }
+
+ private applyBodyStyle(
+ sheet: import('exceljs').Worksheet,
+ columns: TableExportColumn[],
+ rowCount: number,
+ templateStyle?: TemplateStyleSnapshot,
+ ): void {
+ for (let rowIndex = 2; rowIndex <= rowCount + 1; rowIndex += 1) {
+ const row = sheet.getRow(rowIndex);
+ const isEven = (rowIndex - 1) % 2 === 0;
+ const templateRowStyle = isEven
+ ? (templateStyle?.bodyAltStyle ?? templateStyle?.bodyStyle)
+ : (templateStyle?.bodyStyle ?? templateStyle?.bodyAltStyle);
+
+ columns.forEach((column, columnIndex) => {
+ const cell = row.getCell(columnIndex + 1);
+ cell.font = this.cloneStyle(templateRowStyle?.font) || { name: 'Calibri', size: 11, color: { argb: 'FF1F2937' } };
+ cell.border = this.cloneStyle(templateRowStyle?.border) || this.getDefaultBorder();
+ cell.alignment = this.cloneStyle(templateRowStyle?.alignment) || this.getAlignment(column.type);
+
+ if (templateRowStyle?.fill) {
+ const fill = this.cloneStyle(templateRowStyle.fill);
+ if (fill) cell.fill = fill;
+ } else if (isEven) {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFF7FAFF' },
+ };
+ }
+
+ if (column.type === 'number') cell.numFmt = '#,##0.00';
+ if (column.type === 'currency') cell.numFmt = '"R$" #,##0.00';
+ if (column.type === 'date') cell.numFmt = 'dd/mm/yyyy';
+ if (column.type === 'datetime') cell.numFmt = 'dd/mm/yyyy hh:mm';
+ });
+ }
+ }
+
+ private applyColumnWidths(
+ sheet: import('exceljs').Worksheet,
+ columns: TableExportColumn[],
+ rows: T[],
+ templateStyle?: TemplateStyleSnapshot,
+ ): void {
+ columns.forEach((column, columnIndex) => {
+ if (column.width && column.width > 0) {
+ sheet.getColumn(columnIndex + 1).width = column.width;
+ return;
+ }
+
+ const templateWidth = templateStyle?.columnWidths?.[columnIndex];
+ if (templateWidth && templateWidth > 0) {
+ sheet.getColumn(columnIndex + 1).width = templateWidth;
+ return;
+ }
+
+ const headerLength = (column.header ?? '').length;
+ let maxLength = headerLength;
+
+ rows.forEach((row, rowIndex) => {
+ const value = column.value(row, rowIndex);
+ const printable = this.toPrintableValue(value, column.type);
+ if (printable.length > maxLength) maxLength = printable.length;
+ });
+
+ const target = Math.max(12, Math.min(maxLength + 3, 48));
+ sheet.getColumn(columnIndex + 1).width = target;
+ });
+ }
+
+ private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number): void {
+ if (columnCount <= 0) return;
+ sheet.autoFilter = {
+ from: { row: 1, column: 1 },
+ to: { row: 1, column: columnCount },
+ };
+ }
+
+ private normalizeValue(value: unknown, type?: ExportCellType): string | number | Date | boolean | null {
+ if (value === null || value === undefined || value === '') return null;
+
+ if (type === 'number' || type === 'currency') {
+ const numeric = this.toNumber(value);
+ return numeric ?? String(value);
+ }
+
+ if (type === 'date' || type === 'datetime') {
+ const parsedDate = this.toDate(value);
+ return parsedDate ?? String(value);
+ }
+
+ if (type === 'boolean') {
+ if (typeof value === 'boolean') return value;
+ return this.normalizeBoolean(value);
+ }
+
+ return String(value);
+ }
+
+ private toPrintableValue(value: unknown, type?: ExportCellType): string {
+ if (value === null || value === undefined || value === '') return '';
+
+ if (type === 'date' || type === 'datetime') {
+ const parsedDate = this.toDate(value);
+ if (!parsedDate) return String(value);
+ const datePart = `${this.pad2(parsedDate.getDate())}/${this.pad2(parsedDate.getMonth() + 1)}/${parsedDate.getFullYear()}`;
+ if (type === 'date') return datePart;
+ return `${datePart} ${this.pad2(parsedDate.getHours())}:${this.pad2(parsedDate.getMinutes())}`;
+ }
+
+ if (type === 'number' || type === 'currency') {
+ const numeric = this.toNumber(value);
+ if (numeric === null) return String(value);
+ if (type === 'currency') {
+ return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(numeric);
+ }
+ return new Intl.NumberFormat('pt-BR').format(numeric);
+ }
+
+ if (type === 'boolean') {
+ return this.normalizeBoolean(value) ? 'Sim' : 'Nao';
+ }
+
+ return String(value);
+ }
+
+ private toNumber(value: unknown): number | null {
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : null;
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ const normalized = trimmed
+ .replace(/[^\d,.-]/g, '')
+ .replace(/\.(?=\d{3}(\D|$))/g, '')
+ .replace(',', '.');
+ const parsed = Number(normalized);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ return null;
+ }
+
+ private toDate(value: unknown): Date | null {
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value;
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ const brDate = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}))?$/);
+ if (brDate) {
+ const day = Number(brDate[1]);
+ const month = Number(brDate[2]) - 1;
+ const year = Number(brDate[3]);
+ const hour = Number(brDate[4] ?? '0');
+ const minute = Number(brDate[5] ?? '0');
+ const parsedBr = new Date(year, month, day, hour, minute);
+ return Number.isNaN(parsedBr.getTime()) ? null : parsedBr;
+ }
+
+ const parsed = new Date(trimmed);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ }
+
+ return null;
+ }
+
+ private normalizeBoolean(value: unknown): boolean {
+ if (typeof value === 'boolean') return value;
+ const normalized = String(value ?? '')
+ .trim()
+ .toLowerCase();
+ return normalized === 'true' || normalized === '1' || normalized === 'sim' || normalized === 'yes';
+ }
+
+ private ensureXlsxExtension(fileName: string): string {
+ const safe = (fileName ?? 'export').trim() || 'export';
+ return safe.toLowerCase().endsWith('.xlsx') ? safe : `${safe}.xlsx`;
+ }
+
+ private sanitizeSheetName(name: string): string {
+ const safe = (name ?? 'Dados').replace(/[\\/*?:[\]]/g, '').trim();
+ return (safe || 'Dados').slice(0, 31);
+ }
+
+ private shouldExcludeColumnByHeader(header: string | undefined): boolean {
+ const normalized = this.normalizeHeader(header);
+ if (!normalized) return false;
+
+ const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
+ if (!tokens.length) return false;
+
+ return tokens.includes('id') || tokens.includes('item');
+ }
+
+ private normalizeHeader(value: string | undefined): string {
+ return (value ?? '')
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .trim();
+ }
+
+ private downloadBuffer(buffer: ArrayBuffer, fileName: string): void {
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
+ }
+
+ private getAlignment(type?: ExportCellType): Partial {
+ if (type === 'number' || type === 'currency') {
+ return { vertical: 'middle', horizontal: 'right' };
+ }
+ if (type === 'boolean') {
+ return { vertical: 'middle', horizontal: 'center' };
+ }
+ return { vertical: 'middle', horizontal: 'left', wrapText: true };
+ }
+
+ private getDefaultBorder(): Partial {
+ return {
+ top: { style: 'thin', color: { argb: 'FFD6DCE8' } },
+ left: { style: 'thin', color: { argb: 'FFD6DCE8' } },
+ right: { style: 'thin', color: { argb: 'FFD6DCE8' } },
+ bottom: { style: 'thin', color: { argb: 'FFD6DCE8' } },
+ };
+ }
+
+ private pad2(value: number): string {
+ return value.toString().padStart(2, '0');
+ }
+
+ private async extractTemplateStyle(
+ excelJsModule: typeof import('exceljs'),
+ templateBuffer: ArrayBuffer | null,
+ ): Promise {
+ if (!templateBuffer) return undefined;
+
+ try {
+ const workbook = new excelJsModule.Workbook();
+ await workbook.xlsx.load(templateBuffer);
+ const sheet = workbook.getWorksheet(1);
+ if (!sheet) return undefined;
+
+ const headerRow = sheet.getRow(1);
+ const headerCount = Math.max(headerRow.actualCellCount, 1);
+ const headerStyles: CellStyleSnapshot[] = [];
+ for (let col = 1; col <= headerCount; col += 1) {
+ headerStyles.push(this.captureCellStyle(headerRow.getCell(col)));
+ }
+
+ const bodyStyle = this.captureFirstStyledCellRow(sheet.getRow(2));
+ const bodyAltStyle = this.captureFirstStyledCellRow(sheet.getRow(3));
+ const columnWidths = (sheet.columns ?? []).map((column) => column.width);
+
+ return { headerStyles, bodyStyle, bodyAltStyle, columnWidths };
+ } catch {
+ return undefined;
+ }
+ }
+
+ private async resolveTemplateStyle(
+ excelJsModule: typeof import('exceljs'),
+ templateBuffer: ArrayBuffer | null,
+ ): Promise {
+ if (templateBuffer) {
+ const style = await this.extractTemplateStyle(excelJsModule, templateBuffer);
+ if (style) this.cachedDefaultTemplateStyle = style;
+ return style;
+ }
+
+ return this.cachedDefaultTemplateStyle;
+ }
+
+ private async getDefaultTemplateBuffer(): Promise {
+ if (this.defaultTemplateBufferPromise) {
+ return this.defaultTemplateBufferPromise;
+ }
+
+ this.defaultTemplateBufferPromise = this.fetchDefaultTemplateBuffer();
+ const buffer = await this.defaultTemplateBufferPromise;
+ if (!buffer) this.defaultTemplateBufferPromise = null;
+ return buffer;
+ }
+
+ private async fetchDefaultTemplateBuffer(): Promise {
+ try {
+ const params = new HttpParams().set('_', `${Date.now()}`);
+ const blob = await firstValueFrom(
+ this.http.get(`${this.templatesApiBase}/planilha-geral`, {
+ params,
+ responseType: 'blob',
+ })
+ );
+ return await blob.arrayBuffer();
+ } catch {
+ return null;
+ }
+ }
+
+ private captureFirstStyledCellRow(row: import('exceljs').Row): CellStyleSnapshot | undefined {
+ if (!row) return undefined;
+ const cellCount = Math.max(row.actualCellCount, 1);
+ for (let col = 1; col <= cellCount; col += 1) {
+ const captured = this.captureCellStyle(row.getCell(col));
+ if (captured.font || captured.fill || captured.border || captured.alignment) {
+ return captured;
+ }
+ }
+ return undefined;
+ }
+
+ private captureCellStyle(cell: import('exceljs').Cell): CellStyleSnapshot {
+ return {
+ font: this.cloneStyle(cell.font),
+ fill: this.cloneStyle(cell.fill),
+ border: this.cloneStyle(cell.border),
+ alignment: this.cloneStyle(cell.alignment),
+ };
+ }
+
+ private getTemplateStyleByIndex(style: TemplateStyleSnapshot | undefined, index: number): CellStyleSnapshot | undefined {
+ if (!style || !style.headerStyles.length) return undefined;
+ return style.headerStyles[index] ?? style.headerStyles[style.headerStyles.length - 1];
+ }
+
+ private cloneStyle(value: T | undefined): T | undefined {
+ if (!value) return undefined;
+ try {
+ return JSON.parse(JSON.stringify(value)) as T;
+ } catch {
+ return value;
+ }
+ }
+}
diff --git a/src/app/services/users.service.ts b/src/app/services/users.service.ts
index 115f3ce..8dd2c9d 100644
--- a/src/app/services/users.service.ts
+++ b/src/app/services/users.service.ts
@@ -3,8 +3,9 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
-export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
+export type UserPermission = 'sysadmin' | 'gestor' | 'financeiro' | 'cliente';
export type UserDto = {
id: string;
@@ -60,8 +61,7 @@ export class UsersService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
create(payload: CreateUserPayload): Observable {
diff --git a/src/app/services/vigencia.service.ts b/src/app/services/vigencia.service.ts
index eb65938..c6bcb54 100644
--- a/src/app/services/vigencia.service.ts
+++ b/src/app/services/vigencia.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
+import { buildApiBaseUrl } from '../utils/api-base.util';
export type SortDir = 'asc' | 'desc';
@@ -76,8 +77,7 @@ export class VigenciaService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
- const raw = (environment.apiUrl || '').replace(/\/+$/, '');
- this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+ this.baseApi = buildApiBaseUrl(environment.apiUrl);
}
getVigencia(opts: { search?: string; client?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable> {
diff --git a/src/app/utils/account-operator.util.spec.ts b/src/app/utils/account-operator.util.spec.ts
new file mode 100644
index 0000000..ed1c014
--- /dev/null
+++ b/src/app/utils/account-operator.util.spec.ts
@@ -0,0 +1,80 @@
+import {
+ DEFAULT_ACCOUNT_COMPANIES,
+ mergeAccountCompaniesWithDefaults,
+ normalizeConta,
+ resolveEmpresaByConta,
+ resolveOperadoraContext,
+ sameConta,
+} from './account-operator.util';
+
+describe('account-operator.util', () => {
+ it('normaliza contas removendo zeros a esquerda', () => {
+ expect(normalizeConta('0455371844')).toBe('455371844');
+ expect(normalizeConta('000187890982')).toBe('187890982');
+ });
+
+ it('compara contas normalizadas', () => {
+ expect(sameConta('0435288088', '435288088')).toBeTrue();
+ expect(sameConta('172593311', '172593840')).toBeFalse();
+ });
+
+ it('resolve empresa por conta com regras deterministicas obrigatorias', () => {
+ expect(resolveEmpresaByConta('455371844', [])).toBe('VIVO MACROPHONY');
+ expect(resolveEmpresaByConta('460161507', [])).toBe('VIVO MACROPHONY');
+ expect(resolveEmpresaByConta('187890982', [])).toBe('CLARO LINE MÓVEL');
+ expect(resolveEmpresaByConta('TIM', [])).toBe('TIM LINE MÓVEL');
+ });
+
+ it('mescla lista da API com defaults sem perder contas obrigatorias', () => {
+ const merged = mergeAccountCompaniesWithDefaults([
+ { empresa: 'VIVO MACROPHONY', contas: ['0430237019'] },
+ ]);
+
+ const vivo = merged.find((group) => group.empresa === 'VIVO MACROPHONY');
+ const contas = (vivo?.contas ?? []).map((value) => normalizeConta(value));
+
+ expect(contas).toContain(normalizeConta('455371844'));
+ expect(contas).toContain(normalizeConta('460161507'));
+ expect(contas).toContain(normalizeConta('0430237019'));
+ });
+
+ it('classifica operadora e grupo da vivo por contexto', () => {
+ const vivo = resolveOperadoraContext({
+ conta: '455371844',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(vivo.operadora).toBe('VIVO');
+ expect(vivo.vivoEmpresaGrupo).toBe('MACROPHONY');
+
+ const claro = resolveOperadoraContext({
+ conta: '187890982',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(claro.operadora).toBe('CLARO');
+ expect(claro.vivoEmpresaGrupo).toBeNull();
+
+ const tim = resolveOperadoraContext({
+ empresaConta: 'TIM LINE MÓVEL',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(tim.operadora).toBe('TIM');
+
+ const timByConta = resolveOperadoraContext({
+ conta: 'TIM',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(timByConta.operadora).toBe('TIM');
+ });
+
+ it('prioriza mapeamento deterministico por conta mesmo com empresa da linha divergente', () => {
+ const vivoDeterministico = resolveOperadoraContext({
+ conta: '455371844',
+ empresaConta: 'VIVO LINE MÓVEL',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+
+ expect(vivoDeterministico.operadora).toBe('VIVO');
+ expect(vivoDeterministico.empresaConta).toBe('VIVO MACROPHONY');
+ expect(vivoDeterministico.vivoEmpresaGrupo).toBe('MACROPHONY');
+ });
+});
diff --git a/src/app/utils/account-operator.util.ts b/src/app/utils/account-operator.util.ts
new file mode 100644
index 0000000..3dab6fc
--- /dev/null
+++ b/src/app/utils/account-operator.util.ts
@@ -0,0 +1,176 @@
+import { normalizeAccentInsensitive } from './text-normalization.util';
+
+export type OperadoraNome = 'VIVO' | 'CLARO' | 'TIM' | 'OUTRA';
+export type OperadoraFiltro = 'TODOS' | 'VIVO' | 'CLARO' | 'TIM';
+export type VivoEmpresaGrupo = 'MACROPHONY' | 'LINE MOVEL' | 'OUTRA';
+
+export interface AccountCompanyOption {
+ empresa: string;
+ contas: string[];
+}
+
+export interface OperadoraResolution {
+ operadora: OperadoraNome;
+ empresaConta: string;
+ vivoEmpresaGrupo: VivoEmpresaGrupo | null;
+}
+
+export const DEFAULT_ACCOUNT_COMPANIES: AccountCompanyOption[] = [
+ { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840', '187890982'] },
+ { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844', '455371844', '460161507'] },
+ { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] },
+ { empresa: 'TIM LINE MÓVEL', contas: ['TIM'] },
+];
+
+const DEFAULT_EMPRESA_BY_CONTA = buildDefaultEmpresaByConta();
+
+function buildDefaultEmpresaByConta(): Map {
+ const result = new Map();
+
+ DEFAULT_ACCOUNT_COMPANIES.forEach((group) => {
+ (group.contas ?? []).forEach((conta) => {
+ const normalized = normalizeConta(conta);
+ if (!normalized) return;
+ result.set(normalized, group.empresa);
+ });
+ });
+
+ return result;
+}
+
+function normalizeEmpresaKey(value: unknown): string {
+ return normalizeAccentInsensitive(value, 'upper').replace(/[^A-Z0-9]/g, '');
+}
+
+function normalizeContas(contas: unknown): string[] {
+ if (!Array.isArray(contas)) return [];
+
+ const result: string[] = [];
+ const seen = new Set();
+
+ contas.forEach((value) => {
+ const trimmed = String(value ?? '').trim();
+ if (!trimmed) return;
+ const normalized = normalizeConta(trimmed);
+ if (!normalized || seen.has(normalized)) return;
+ seen.add(normalized);
+ result.push(trimmed);
+ });
+
+ return result;
+}
+
+export function normalizeConta(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+
+ if (!/^\d+$/.test(raw)) {
+ return normalizeAccentInsensitive(raw, 'upper');
+ }
+
+ const noLeadingZero = raw.replace(/^0+/, '');
+ return noLeadingZero || '0';
+}
+
+export function sameConta(a: unknown, b: unknown): boolean {
+ return normalizeConta(a) === normalizeConta(b);
+}
+
+export function mergeAccountCompaniesWithDefaults(
+ source: AccountCompanyOption[] | null | undefined
+): AccountCompanyOption[] {
+ const merged = new Map();
+ const contaSeenByEmpresa = new Map>();
+
+ const addGroup = (empresaRaw: unknown, contasRaw: unknown) => {
+ const empresa = String(empresaRaw ?? '').trim();
+ if (!empresa) return;
+
+ const key = normalizeEmpresaKey(empresa);
+ const contas = normalizeContas(contasRaw);
+
+ if (!merged.has(key)) {
+ merged.set(key, { empresa, contas: [] });
+ contaSeenByEmpresa.set(key, new Set());
+ }
+
+ const record = merged.get(key);
+ const seen = contaSeenByEmpresa.get(key);
+ if (!record || !seen) return;
+
+ contas.forEach((conta) => {
+ const normalized = normalizeConta(conta);
+ if (!normalized || seen.has(normalized)) return;
+ seen.add(normalized);
+ record.contas.push(conta);
+ });
+ };
+
+ (source ?? []).forEach((group) => addGroup(group?.empresa, group?.contas));
+ DEFAULT_ACCOUNT_COMPANIES.forEach((group) => addGroup(group.empresa, group.contas));
+
+ return Array.from(merged.values());
+}
+
+export function resolveEmpresaByConta(
+ conta: unknown,
+ accountCompanies: AccountCompanyOption[] | null | undefined
+): string {
+ const target = normalizeConta(conta);
+ if (!target) return '';
+
+ const deterministic = DEFAULT_EMPRESA_BY_CONTA.get(target);
+ if (deterministic) return deterministic;
+
+ const found = (accountCompanies ?? []).find((group) =>
+ (group.contas ?? []).some((candidate) => sameConta(candidate, target))
+ );
+ return found?.empresa ?? '';
+}
+
+function resolveOperadoraByEmpresa(empresa: unknown): OperadoraNome {
+ const normalized = normalizeEmpresaKey(empresa);
+ if (!normalized) return 'OUTRA';
+ if (normalized.includes('CLARO')) return 'CLARO';
+ if (normalized.includes('TIM')) return 'TIM';
+ if (normalized.includes('VIVO') || normalized.includes('MACROPHONY')) return 'VIVO';
+ return 'OUTRA';
+}
+
+function resolveVivoEmpresaGrupo(empresa: unknown): VivoEmpresaGrupo {
+ const normalized = normalizeEmpresaKey(empresa);
+ if (!normalized) return 'OUTRA';
+ if (normalized.includes('MACROPHONY')) return 'MACROPHONY';
+ if (normalized.includes('LINEMOVEL') || normalized.includes('LINEMOV')) return 'LINE MOVEL';
+ return 'OUTRA';
+}
+
+export function resolveOperadoraContext(input: {
+ conta?: unknown;
+ empresaConta?: unknown;
+ accountCompanies?: AccountCompanyOption[] | null;
+}): OperadoraResolution {
+ const contaRaw = String(input.conta ?? '').trim();
+ const contaEmpresaRaw = String(input.empresaConta ?? '').trim();
+ const empresaFromConta = resolveEmpresaByConta(input.conta, input.accountCompanies);
+ // Regras por conta (determinísticas) têm prioridade sobre texto livre da linha.
+ const empresaConta = empresaFromConta || contaEmpresaRaw;
+
+ let operadora = resolveOperadoraByEmpresa(empresaConta);
+ if (operadora === 'OUTRA' && empresaFromConta) {
+ operadora = resolveOperadoraByEmpresa(empresaFromConta);
+ }
+ if (operadora === 'OUTRA' && contaRaw) {
+ operadora = resolveOperadoraByEmpresa(contaRaw);
+ }
+
+ const vivoEmpresaGrupo = operadora === 'VIVO'
+ ? resolveVivoEmpresaGrupo(empresaConta || empresaFromConta || contaRaw)
+ : null;
+
+ return {
+ operadora,
+ empresaConta: empresaConta || '',
+ vivoEmpresaGrupo,
+ };
+}
diff --git a/src/app/utils/api-base.util.ts b/src/app/utils/api-base.util.ts
new file mode 100644
index 0000000..b5269ff
--- /dev/null
+++ b/src/app/utils/api-base.util.ts
@@ -0,0 +1,11 @@
+export function buildApiBaseUrl(apiUrl: string | null | undefined): string {
+ const raw = (apiUrl ?? '').toString().trim().replace(/\/+$/, '');
+ if (!raw) return '/api';
+ return raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
+}
+
+export function buildApiEndpoint(apiUrl: string | null | undefined, resourcePath: string): string {
+ const base = buildApiBaseUrl(apiUrl);
+ const cleanedPath = (resourcePath ?? '').toString().trim().replace(/^\/+/, '');
+ return cleanedPath ? `${base}/${cleanedPath}` : base;
+}
diff --git a/src/app/utils/pagination.util.ts b/src/app/utils/pagination.util.ts
new file mode 100644
index 0000000..3e49a16
--- /dev/null
+++ b/src/app/utils/pagination.util.ts
@@ -0,0 +1,35 @@
+export function computeTotalPages(total: number, pageSize: number): number {
+ const safeTotal = Number.isFinite(total) ? Math.max(0, total) : 0;
+ const safeSize = Number.isFinite(pageSize) ? Math.max(1, pageSize) : 1;
+ return Math.max(1, Math.ceil(safeTotal / safeSize));
+}
+
+export function clampPage(page: number, totalPages: number): number {
+ return Math.max(1, Math.min(totalPages, page));
+}
+
+export function buildPageNumbers(currentPage: number, totalPages: number, maxVisible = 5): number[] {
+ const safeTotal = Math.max(1, totalPages);
+ const safeCurrent = clampPage(currentPage, safeTotal);
+ const safeMax = Math.max(1, maxVisible);
+
+ let start = Math.max(1, safeCurrent - Math.floor(safeMax / 2));
+ let end = Math.min(safeTotal, start + (safeMax - 1));
+ start = Math.max(1, end - (safeMax - 1));
+
+ const pages: number[] = [];
+ for (let i = start; i <= end; i += 1) pages.push(i);
+ return pages;
+}
+
+export function computePageStart(total: number, page: number, pageSize: number): number {
+ if (!Number.isFinite(total) || total <= 0) return 0;
+ return (clampPage(page, computeTotalPages(total, pageSize)) - 1) * Math.max(1, pageSize) + 1;
+}
+
+export function computePageEnd(total: number, page: number, pageSize: number): number {
+ if (!Number.isFinite(total) || total <= 0) return 0;
+ const safeSize = Math.max(1, pageSize);
+ const safePage = clampPage(page, computeTotalPages(total, safeSize));
+ return Math.min(safePage * safeSize, total);
+}
diff --git a/src/app/utils/text-normalization.util.ts b/src/app/utils/text-normalization.util.ts
new file mode 100644
index 0000000..31a1ff3
--- /dev/null
+++ b/src/app/utils/text-normalization.util.ts
@@ -0,0 +1,16 @@
+export type NormalizeCaseMode = 'upper' | 'lower' | 'none';
+
+export function normalizeAccentInsensitive(
+ value: unknown,
+ caseMode: NormalizeCaseMode = 'upper'
+): string {
+ const normalized = (value ?? '')
+ .toString()
+ .trim()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '');
+
+ if (caseMode === 'lower') return normalized.toLowerCase();
+ if (caseMode === 'none') return normalized;
+ return normalized.toUpperCase();
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index ef19921..a2b81e4 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -4,6 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
+ "noPropertyAccessFromIndexSignature": false,
"types": [
"node"
]