feat(front): pagina faturamento + integracao billing

This commit is contained in:
Eduardo 2026-01-05 17:57:05 -03:00
parent c24e55929b
commit 47485a867b
17 changed files with 5287 additions and 977 deletions

View File

@ -1,13 +1,22 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Home } from './pages/home/home'; import { Home } from './pages/home/home';
import { Register } from './pages/register/register'; import { Register } from './pages/register/register';
import { LoginComponent } from './pages/login/login'; import { LoginComponent } from './pages/login/login';
import { Geral } from './pages/geral/geral'; import { Geral } from './pages/geral/geral';
import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard'; import { authGuard } from './guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
{ path: "register", component: Register }, { path: 'register', component: Register },
{ path: "login", component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'geral', component: Geral, canActivate: [authGuard] }, { path: 'geral', component: Geral, canActivate: [authGuard] },
{ path: 'mureg', component: Mureg, canActivate: [authGuard] },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
{ path: '**', redirectTo: '' },
]; ];

View File

@ -3,7 +3,7 @@
[class.header-scrolled]="isScrolled" [class.header-scrolled]="isScrolled"
> >
<div class="header-top"> <div class="header-top">
<!-- ESQUERDA: HAMBURGUER (só no /geral) + LOGO --> <!-- ESQUERDA: HAMBURGUER (logado) + LOGO -->
<div class="left-area"> <div class="left-area">
<button <button
*ngIf="isLoggedHeader" *ngIf="isLoggedHeader"
@ -24,7 +24,7 @@
</a> </a>
</div> </div>
<!-- ✅ MENU HOME: só aparece fora do /geral --> <!-- ✅ MENU HOME: só aparece fora do logado -->
<nav class="menu" *ngIf="!isLoggedHeader"> <nav class="menu" *ngIf="!isLoggedHeader">
<a href="https://www.linemovel.com.br/sobrenos" class="menu-item" target="_blank">O que é a Line Móvel?</a> <a href="https://www.linemovel.com.br/sobrenos" class="menu-item" target="_blank">O que é a Line Móvel?</a>
<a href="https://www.linemovel.com.br/empresas" class="menu-item" target="_blank">Para sua empresa</a> <a href="https://www.linemovel.com.br/empresas" class="menu-item" target="_blank">Para sua empresa</a>
@ -32,7 +32,7 @@
<a href="https://www.linemovel.com.br/indique" class="menu-item" target="_blank">Indique um amigo</a> <a href="https://www.linemovel.com.br/indique" class="menu-item" target="_blank">Indique um amigo</a>
</nav> </nav>
<!-- ✅ BOTÕES: só aparecem fora do /geral --> <!-- ✅ BOTÕES: só aparecem fora do logado -->
<div class="btn-area" *ngIf="!isLoggedHeader"> <div class="btn-area" *ngIf="!isLoggedHeader">
<button type="button" class="btn btn-cadastrar" [routerLink]="['/register']"> <button type="button" class="btn btn-cadastrar" [routerLink]="['/register']">
Cadastre-se Cadastre-se
@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<!-- ✅ FAIXA (SÓ NA HOME) com degradê igual footer --> <!-- ✅ FAIXA (SÓ NA HOME) -->
<div class="header-bar footer-gradient" *ngIf="isHome"> <div class="header-bar footer-gradient" *ngIf="isHome">
<span class="header-bar-text"> <span class="header-bar-text">
Somos a escolha certa para estar sempre conectado! Somos a escolha certa para estar sempre conectado!
@ -52,14 +52,14 @@
</div> </div>
</header> </header>
<!-- ✅ OVERLAY (só no /geral) --> <!-- ✅ OVERLAY (logado) -->
<div <div
class="menu-overlay" class="menu-overlay"
*ngIf="isLoggedHeader && menuOpen" *ngIf="isLoggedHeader && menuOpen"
(click)="closeMenu()" (click)="closeMenu()"
></div> ></div>
<!-- ✅ MENU LATERAL (só no /geral) --> <!-- ✅ MENU LATERAL (logado) -->
<aside <aside
*ngIf="isLoggedHeader" *ngIf="isLoggedHeader"
class="side-menu" class="side-menu"
@ -86,14 +86,24 @@
</div> </div>
<div class="side-menu-body"> <div class="side-menu-body">
<a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-clipboard-data me-2"></i> Controle de Contratos
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()"> <a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-sim me-2"></i> Gerenciar Linhas <i class="bi bi-sim me-2"></i> Gerenciar Linhas
</a> </a>
<!-- ✅ NOVO: FATURAMENTO -->
<a routerLink="/faturamento" class="side-item" (click)="closeMenu()">
<i class="bi bi-receipt me-2"></i> Faturamento
</a>
<a routerLink="/mureg" class="side-item" (click)="closeMenu()">
<i class="bi bi-table me-2"></i> Mureg
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-clipboard-data me-2"></i> Controle de Contratos
</a>
<a routerLink="/geral" class="side-item" (click)="closeMenu()"> <a routerLink="/geral" class="side-item" (click)="closeMenu()">
<i class="bi bi-people me-2"></i> Gerenciar Clientes <i class="bi bi-people me-2"></i> Gerenciar Clientes
</a> </a>

View File

@ -74,14 +74,12 @@
display: grid; display: grid;
grid-template-columns: auto 1fr auto; /* esquerda | centro | direita */ grid-template-columns: auto 1fr auto; /* esquerda | centro | direita */
align-items: center; align-items: center;
/* como agora é grid, não usamos space-between */
justify-content: unset; justify-content: unset;
} }
.menu { .menu {
width: 100%; width: 100%;
justify-content: center; /* ✅ centraliza os links no centro */ justify-content: center; /* ✅ centraliza os links */
} }
} }
@ -93,7 +91,7 @@
} }
/* ===================== */ /* ===================== */
/* HAMBURGUER (GERAL) */ /* HAMBURGUER (LOGADO) */
/* ===================== */ /* ===================== */
.hamburger-btn { .hamburger-btn {
width: 44px; width: 44px;
@ -195,12 +193,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
/* ✅ nunca quebrar linha */
flex-wrap: nowrap; flex-wrap: nowrap;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
/* ✅ não empurrar botões */
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@ -210,7 +206,6 @@
@media (max-width: 1100px) { gap: 12px; } @media (max-width: 1100px) { gap: 12px; }
@media (max-width: 1024px) { gap: 10px; } @media (max-width: 1024px) { gap: 10px; }
/* telas menores: some o menu */
@media (max-width: 992px) { @media (max-width: 992px) {
display: none; display: none;
} }
@ -226,7 +221,6 @@
padding: 10px 10px; padding: 10px 10px;
border-radius: 12px; border-radius: 12px;
white-space: nowrap; white-space: nowrap;
transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease;
@ -297,7 +291,7 @@
} }
.btn-cadastrar { .btn-cadastrar {
background: #E1E1E1; /* ✅ sólido (sem transparência) */ background: #E1E1E1;
color: #000; color: #000;
} }
@ -333,7 +327,6 @@
} }
} }
/* degradê igual ao footer */
.footer-gradient { .footer-gradient {
background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%); background: linear-gradient(90deg, #0B2BD6 0%, #6A55FF 40%, #E33DCF 100%);
} }
@ -348,9 +341,8 @@
} }
/* ===================================================== */ /* ===================================================== */
/* MENU LATERAL (GERAL) */ /* MENU LATERAL (LOGADO) */
/* ===================================================== */ /* ===================================================== */
.menu-overlay { .menu-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -454,7 +446,7 @@
} }
/* ========================================= */ /* ========================================= */
/* ✅ OVERRIDE BOOTSTRAP: SEM TRANSPARÊNCIA */ /* OVERRIDE BOOTSTRAP */
/* ========================================= */ /* ========================================= */
.btn.btn-cadastrar, .btn.btn-cadastrar,
.btn.btn-login { .btn.btn-login {
@ -473,11 +465,9 @@
filter: none !important; filter: none !important;
} }
/* mantém as cores originais até quando clica */
.btn.btn-cadastrar:active { background: #E1E1E1 !important; } .btn.btn-cadastrar:active { background: #E1E1E1 !important; }
.btn.btn-login:active { background: var(--brand) !important; border-color: var(--brand) !important; color: #fff !important; } .btn.btn-login:active { background: var(--brand) !important; border-color: var(--brand) !important; color: #fff !important; }
/* remove “inset” do bootstrap */
.btn.btn-cadastrar:active, .btn.btn-cadastrar:active,
.btn.btn-login:active { .btn.btn-login:active {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10) !important; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10) !important;

View File

@ -30,8 +30,11 @@ export class Header {
this.isHome = (url === '/' || url === ''); this.isHome = (url === '/' || url === '');
// ✅ considera header logado quando está em /geral // ✅ considera header logado quando está em rotas internas
this.isLoggedHeader = url.startsWith('/geral'); // (agora inclui MUREG)
this.isLoggedHeader =
url.startsWith('/geral') ||
url.startsWith('/mureg');
// ✅ ao trocar de rota, fecha o menu // ✅ ao trocar de rota, fecha o menu
this.menuOpen = false; this.menuOpen = false;

View File

@ -0,0 +1,288 @@
<section class="billing-page">
<span class="page-blob blob-1" aria-hidden="true"></span>
<span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-billing">
<div class="billing-card" data-animate>
<!-- HEADER (igual Geral) -->
<div class="billing-header">
<div class="header-row-top">
<div class="title-badge" data-animate>
<i class="bi bi-cash-stack"></i> Financeiro
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">Faturamento</h5>
<small class="subtitle">Visualize dados de Pessoa Física e Pessoa Jurídica</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button class="btn btn-glass btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
</div>
<!-- FILTROS (tabs + controls no mesmo padrão do Geral) -->
<div class="filters-row mt-4" data-animate>
<!-- Tipo PF/PJ como tabs -->
<div class="filter-tabs">
<button
type="button"
class="filter-tab"
[class.active]="tipo === 'PF'"
(click)="setTipo('PF')"
[disabled]="loading">
<i class="bi bi-person me-1"></i> Pessoa Física
</button>
<button
type="button"
class="filter-tab"
[class.active]="tipo === 'PJ'"
(click)="setTipo('PJ')"
[disabled]="loading">
<i class="bi bi-building me-1"></i> Pessoa Jurídica
</button>
</div>
<!-- Cliente (select glass) -->
<div class="client-filter">
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="client"
(change)="applyClientFilter()"
[disabled]="loading"
title="Filtrar por cliente">
<option value="">Todos os clientes</option>
<option *ngFor="let c of clients" [value]="c">{{ c }}</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div>
<button
class="btn btn-outline-secondary btn-sm btn-clear-client"
type="button"
(click)="clearClient()"
[disabled]="loading || !client"
title="Limpar filtro de cliente">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<!-- CONTROLS (search + page size igual Geral) -->
<div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i
class="bi"
[class.bi-search]="!loading"
[class.bi-hourglass-split]="loading"
[class.text-brand]="loading"></i>
</span>
<input
type="text"
class="form-control"
placeholder="Buscar cliente..."
[(ngModel)]="search"
(ngModelChange)="onSearchChange()"
/>
<button
class="btn btn-outline-secondary btn-clear"
type="button"
(click)="search=''; onSearchChange()"
[disabled]="loading || !search"
title="Limpar busca">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase"
style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
</span>
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="pageSize"
(change)="changePageSize()"
[disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div>
</div>
</div>
<!-- ERRO -->
<div *ngIf="errorMessage" class="alert alert-danger mt-3 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
{{ errorMessage }}
</div>
</div>
<!-- BODY (tabela no padrão Geral) -->
<div class="billing-body">
<div class="table-meta" *ngIf="result">
<span class="meta-pill">
<i class="bi bi-collection me-1"></i>
Total: <strong>{{ result.total }}</strong>
</span>
<span class="meta-pill">
<i class="bi bi-layers me-1"></i>
Página: <strong>{{ page }}</strong> / <strong>{{ totalPages }}</strong>
</span>
<span class="meta-loading" *ngIf="loading">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando...
</span>
</div>
<div class="table-wrap table-wrap-tall">
<table class="table table-modern align-middle text-center mb-0 billing-table">
<thead>
<tr>
<th class="sortable" (click)="toggleSort('item')">
<div class="th-content justify-content-center">
ITEM
<span class="sort-caret" [class.active]="sortBy==='item'">
<i [class]="sortIcon('item')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('cliente')">
<div class="th-content justify-content-center">
CLIENTE
<span class="sort-caret" [class.active]="sortBy==='cliente'">
<i [class]="sortIcon('cliente')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('qtdlinhas')">
<div class="th-content justify-content-center">
QTD LINHAS
<span class="sort-caret" [class.active]="sortBy==='qtdlinhas'">
<i [class]="sortIcon('qtdlinhas')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('franquiavivo')">
<div class="th-content justify-content-center">
FRANQUIA VIVO
<span class="sort-caret" [class.active]="sortBy==='franquiavivo'">
<i [class]="sortIcon('franquiavivo')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('valorcontratovivo')">
<div class="th-content justify-content-center">
CONTRATO VIVO
<span class="sort-caret" [class.active]="sortBy==='valorcontratovivo'">
<i [class]="sortIcon('valorcontratovivo')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('franquialine')">
<div class="th-content justify-content-center">
FRANQUIA LINE
<span class="sort-caret" [class.active]="sortBy==='franquialine'">
<i [class]="sortIcon('franquialine')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('valorcontratoline')">
<div class="th-content justify-content-center">
CONTRATO LINE
<span class="sort-caret" [class.active]="sortBy==='valorcontratoline'">
<i [class]="sortIcon('valorcontratoline')"></i>
</span>
</div>
</th>
<th class="sortable" (click)="toggleSort('lucro')">
<div class="th-content justify-content-center">
LUCRO
<span class="sort-caret" [class.active]="sortBy==='lucro'">
<i [class]="sortIcon('lucro')"></i>
</span>
</div>
</th>
<th>
APARELHO
</th>
<th>
PAGAMENTO
</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (result.items?.length ?? 0) === 0">
<td colspan="10" class="text-center py-5 empty-state text-muted fw-bold">
<i class="bi bi-inbox me-2"></i>
Nenhum registro encontrado.
</td>
</tr>
<tr *ngFor="let it of result.items; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ it.item }}</td>
<td class="fw-black text-dark td-clip" [title]="it.cliente || ''">{{ it.cliente }}</td>
<td class="fw-bold">{{ it.qtdLinhas ?? 0 }}</td>
<td class="text-end">{{ brl(it.franquiaVivo) }}</td>
<td class="text-end">{{ brl(it.valorContratoVivo) }}</td>
<td class="text-end">{{ brl(it.franquiaLine) }}</td>
<td class="text-end">{{ brl(it.valorContratoLine) }}</td>
<td class="text-end lucro">{{ brl(it.lucro) }}</td>
<td class="text-truncate extra" [title]="it.aparelho || ''">{{ it.aparelho || '-' }}</td>
<td class="text-truncate extra" [title]="it.formaPagamento || ''">{{ it.formaPagamento || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- FOOTER / PAGINAÇÃO (padrão Geral) -->
<div class="billing-footer">
<div class="small text-muted fw-bold">
Página {{ page }} de {{ totalPages }}
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="loading || page <= 1">
<button class="page-link" (click)="prevPage()">Anterior</button>
</li>
<li class="page-item" [class.disabled]="loading || page >= totalPages">
<button class="page-link" (click)="nextPage()">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,566 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--radius-xl: 22px;
--radius-lg: 16px;
--radius-md: 12px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
--page-top-gap: clamp(14px, 4vh, 40px);
--page-bottom-gap: clamp(110px, 14vh, 220px);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
/* PAGE BG igual Geral */
.billing-page {
min-height: 100vh;
padding: 0 12px var(--page-bottom-gap);
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);
}
}
/* BLOBS igual Geral */
.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: .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-billing {
width: 100%;
max-width: 1180px;
position: relative;
z-index: 1;
margin-top: var(--page-top-gap);
margin-bottom: var(--page-bottom-gap);
}
/* CARD glass */
.billing-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
max-height: calc(100vh - 18px) !important;
min-height: 0;
&::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;
}
}
/* HEADER */
.billing-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: 768px) {
grid-template-columns: 1fr;
text-align: center;
gap: 16px;
.title-badge { justify-self: center; margin-bottom: 8px; }
.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.78);
border: 1px solid rgba(227, 61, 207, 0.22);
backdrop-filter: blur(10px);
color: var(--text);
font-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.title {
font-size: 26px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
margin-bottom: 0;
}
.subtitle {
color: rgba(17, 18, 20, 0.65);
font-weight: 700;
}
.header-actions { justify-self: end; }
/* BOTÕES */
.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 */
.filters-row {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.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;
}
}
/* CLIENTE: select glass + botão limpar */
.client-filter {
display: flex;
align-items: center;
gap: 8px;
}
.btn-clear-client {
border-radius: 12px;
}
/* CONTROLS igual Geral */
.controls {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.search-group {
max-width: 320px;
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 {
display: inline-flex;
background: transparent;
border: none;
color: var(--muted);
padding: 0 12px;
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;
}
}
.select-wrapper {
position: relative;
display: inline-block;
min-width: 170px;
@media (max-width: 500px) {
min-width: 150px;
}
}
.select-glass {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
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;
}
.text-brand { color: var(--brand) !important; }
.fw-black { font-weight: 950; }
/* BODY */
.billing-body {
padding: 16px;
background: transparent;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
/* meta pills */
.table-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(17,18,20,0.10);
font-weight: 800;
color: rgba(17,18,20,0.75);
i { color: var(--brand); }
}
.meta-loading {
margin-left: auto;
display: inline-flex;
align-items: center;
color: rgba(17,18,20,0.65);
font-weight: 700;
}
/* TABELA igual Geral */
.table-wrap {
overflow: auto;
height: 100%;
}
.table-wrap-tall {
flex: 1 1 auto;
min-height: 0;
max-height: clamp(760px, 82vh, 1800px) !important;
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(17,18,20,0.10);
background: rgba(255,255,255,0.7);
backdrop-filter: blur(6px);
overscroll-behavior: contain;
}
.table-modern {
width: 100%;
min-width: 1100px;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 2px solid rgba(227, 61, 207, 0.15);
padding: 12px;
color: rgba(17, 18, 20, 0.7);
font-size: 0.78rem;
font-weight: 950;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
cursor: default;
text-align: center !important;
}
tbody tr {
transition: background-color 0.2s;
border-bottom: 1px solid rgba(17,18,20,0.05);
&:hover { background-color: rgba(227, 61, 207, 0.05); }
td { border-bottom: 1px solid rgba(17,18,20,0.04); }
}
td {
padding: 12px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
color: var(--text);
text-align: center !important;
}
.sortable {
cursor: pointer;
user-select: none;
&:hover { color: var(--brand); }
}
}
.th-content {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.sort-caret {
opacity: 0.55;
&.active {
opacity: 1;
color: var(--brand);
}
}
.td-clip {
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.extra {
max-width: 170px;
}
.empty-state {
background: rgba(255,255,255,0.4);
}
.lucro {
font-weight: 950;
}
/* FOOTER */
.billing-footer {
padding: 14px 24px;
border-top: 1px solid rgba(17, 18, 20, 0.06);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
@media (max-width: 768px) {
justify-content: center;
text-align: center;
}
}
.pagination-modern .page-link {
color: var(--blue);
font-weight: 900;
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.1);
background: rgba(255,255,255,0.6);
margin: 0 2px;
&:hover {
transform: translateY(-1px);
border-color: var(--brand);
color: var(--brand);
}
}
.pagination-modern .page-item.disabled .page-link {
opacity: 0.6;
}
/* RESPONSIVO */
@media (max-width: 992px) {
.table-modern { min-width: 1000px; }
}
@media (max-width: 576px) {
:host {
--page-top-gap: 16px;
--page-bottom-gap: 140px;
}
.table-wrap-tall { max-height: 70vh !important; }
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Faturamento } from './faturamento';
describe('Faturamento', () => {
let component: Faturamento;
let fixture: ComponentFixture<Faturamento>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Faturamento]
})
.compileComponents();
fixture = TestBed.createComponent(Faturamento);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,208 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import {
BillingService,
BillingItem,
PagedResult,
BillingSortBy,
SortDir,
TipoCliente
} from '../../services/billing';
@Component({
selector: 'app-faturamento',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './faturamento.html',
styleUrl: './faturamento.scss',
})
export class Faturamento implements OnInit, OnDestroy {
// ===== UI state =====
loading = false;
errorMessage: string | null = null;
// ===== filtros =====
tipo: TipoCliente = 'PF';
search = '';
client = '';
page = 1;
pageSize = 20;
// ===== ordenação =====
sortBy: BillingSortBy = 'cliente';
sortDir: SortDir = 'asc';
// ===== dados =====
result: PagedResult<BillingItem> = { page: 1, pageSize: 20, total: 0, items: [] };
clients: string[] = [];
// ===== subs =====
private sub = new Subscription();
private searchTimer: ReturnType<typeof setTimeout> | null = null;
@ViewChild('errorToast', { static: false }) errorToast?: ElementRef<HTMLDivElement>;
constructor(private billingService: BillingService) {}
ngOnInit(): void {
this.loadClients();
this.loadData();
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.searchTimer) clearTimeout(this.searchTimer);
}
// =========================
// Ações de filtro
// =========================
setTipo(tipo: TipoCliente) {
if (this.tipo === tipo) return;
this.tipo = tipo;
this.page = 1;
this.client = '';
this.loadClients();
this.loadData();
}
onSearchChange() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.loadData();
}, 350);
}
applyClientFilter() {
this.page = 1;
this.loadData();
}
clearClient() {
this.client = '';
this.page = 1;
this.loadData();
}
refresh() {
this.loadClients();
this.loadData();
}
changePageSize() {
this.page = 1;
this.loadData();
}
// =========================
// Ordenação
// =========================
toggleSort(col: BillingSortBy) {
if (this.sortBy === col) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = col;
this.sortDir = 'asc';
}
this.page = 1;
this.loadData();
}
sortIcon(col: BillingSortBy): string {
if (this.sortBy !== col) return 'bi bi-arrow-down-up';
return this.sortDir === 'asc' ? 'bi bi-sort-down' : 'bi bi-sort-up';
}
// =========================
// Paginação
// =========================
get totalPages(): number {
const t = this.result?.total ?? 0;
return Math.max(1, Math.ceil(t / (this.pageSize || 20)));
}
goToPage(p: number) {
const tp = this.totalPages;
const next = Math.min(Math.max(1, p), tp);
if (next === this.page) return;
this.page = next;
this.loadData();
}
prevPage() {
this.goToPage(this.page - 1);
}
nextPage() {
this.goToPage(this.page + 1);
}
// =========================
// Carregamento
// =========================
private loadClients() {
this.sub.add(
this.billingService.getClients(this.tipo).subscribe({
next: (list: string[]) => {
this.clients = list ?? [];
},
error: () => {
this.clients = [];
},
})
);
}
private loadData() {
this.loading = true;
this.errorMessage = null;
this.sub.add(
this.billingService
.getPaged({
tipo: this.tipo,
search: this.search,
client: this.client,
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
sortDir: this.sortDir,
})
.subscribe({
next: (res: PagedResult<BillingItem>) => {
this.result = res ?? { page: this.page, pageSize: this.pageSize, total: 0, items: [] };
this.page = this.result.page ?? this.page;
this.pageSize = this.result.pageSize ?? this.pageSize;
},
error: (err: any) => {
this.errorMessage = err?.error?.message || 'Erro ao carregar faturamento.';
this.result = { page: this.page, pageSize: this.pageSize, total: 0, items: [] };
},
complete: () => {
this.loading = false;
},
})
);
}
// =========================
// Helpers de display
// =========================
brl(v: number | null | undefined): string {
const n = typeof v === 'number' ? v : 0;
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
num(v: number | null | undefined): string {
const n = typeof v === 'number' ? v : 0;
return n.toLocaleString('pt-BR');
}
trackById(_: number, it: BillingItem) {
return it.id;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,292 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-success border-0 shadow" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header border-bottom-0">
<strong class="me-auto text-primary">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark">
{{ toastMessage }}
</div>
</div>
</div>
<section class="mureg-page">
<span class="page-blob blob-1" aria-hidden="true"></span>
<span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-mureg">
<div class="mureg-card" data-animate>
<div class="mureg-header">
<div class="header-row-top">
<div class="title-badge" data-animate>
<i class="bi bi-table"></i> MUREG
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">MUREG</h5>
<small class="subtitle">Gestão de registros MUREG</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<button type="button" class="btn btn-brand btn-sm" (click)="onCreate()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Mureg
</button>
</div>
</div>
<div class="mureg-kpis mt-4 animate-fade-in">
<div class="kpi">
<span class="lbl">Clientes</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ total || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Registros</span>
<span class="val">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupLoadedRecords || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Trocas</span>
<span class="val text-brand">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalTrocas || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl text-success">ICCID</span>
<span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ groupTotalIccids || 0 }}</span>
</span>
</div>
</div>
<div class="controls mt-3 mb-2" data-animate>
<div class="input-group input-group-sm search-group">
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span>
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm"><i class="bi bi-x-lg"></i></button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
</div>
</div>
</div>
</div>
<div class="mureg-body">
<div class="groups-container">
<div class="text-center p-5" *ngIf="loading"><span class="spinner-border text-brand"></span></div>
<div class="empty-group" *ngIf="!loading && pagedClientGroups.length === 0">
Nenhum dado encontrado.
</div>
<div class="group-list" *ngIf="!loading">
<div *ngFor="let g of pagedClientGroups" class="client-group-card" [class.expanded]="expandedGroup === g.cliente">
<div class="group-header" (click)="toggleGroup(g.cliente)">
<div class="group-info">
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.total }} Registros</span>
<span class="badge-pill swap" *ngIf="g.trocas > 0">{{ g.trocas }} Trocas</span>
<span class="badge-pill ok" *ngIf="g.comIccid > 0">{{ g.comIccid }} ICCID</span>
<span class="badge-pill warn" *ngIf="g.semIccid > 0">{{ g.semIccid }} Sem ICCID</span>
</div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Registros do Cliente</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Use o botão à direita para editar</span>
</div>
<div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA ANTIGA</th>
<th>LINHA NOVA</th>
<th>ICCID</th>
<th>DATA MUREG</th>
<th>SITUAÇÃO</th>
<th style="min-width: 80px;">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngIf="groupRows.length === 0">
<td colspan="7" class="text-center py-4 empty-state text-muted fw-bold">Nenhum registro.</td>
</tr>
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item || '-' }}</td>
<td class="text-dark">{{ r.linhaAntiga || '-' }}</td>
<td class="fw-black text-blue">{{ r.linhaNova || '-' }}</td>
<td class="small font-monospace">{{ r.iccid || '-' }}</td>
<td class="text-muted small fw-bold">{{ displayValue('dataDaMureg', r.dataDaMureg) }}</td>
<td>
<span class="status-pill" [class.is-swap]="isTroca(r)" [class.is-same]="!isTroca(r)">
{{ isTroca(r) ? 'TROCA' : 'SEM TROCA' }}
</span>
</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro"><i class="bi bi-pencil-square"></i></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mureg-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} Clientes</div>
<nav><ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading"><button class="page-link" (click)="goToPage(page - 1)">Anterior</button></li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page"><button class="page-link" (click)="goToPage(p)">{{ p }}</button></li>
<li class="page-item" [class.disabled]="page === totalPages || loading"><button class="page-link" (click)="goToPage(page + 1)">Próxima</button></li>
</ul></nav>
</div>
</div>
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<div class="modal-custom" *ngIf="editOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Registro Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeEdit()" [disabled]="editSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
<span *ngIf="!editSaving"><i class="bi bi-check2-circle me-1"></i> Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-card-text me-2"></i> Informações</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Nome do Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataDaMureg" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova</label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="editModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="editModel.iccid" />
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #editLoadingTpl><div class="p-5 text-center text-muted">Preparando edição...</div></ng-template>
</div>
</div>
</div>
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
Nova Mureg
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Criar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box w-100">
<div class="box-header"><span><i class="bi bi-pencil me-2"></i> Preencha os dados</span></div>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Nome do Cliente <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" placeholder="Nome do Cliente" />
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.item" />
</div>
<div class="form-field">
<label>Data Mureg</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataDaMureg" />
</div>
<div class="form-field">
<label>Linha Antiga</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.linhaAntiga" />
</div>
<div class="form-field">
<label>Linha Nova <span class="text-danger">*</span></label>
<input class="form-control form-control-sm fw-bold text-blue" [(ngModel)]="createModel.linhaNova" />
</div>
<div class="form-field span-2">
<label>ICCID</label>
<input class="form-control form-control-sm font-monospace" [(ngModel)]="createModel.iccid" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,310 @@
/* ========================================================== */
/* VARIÁVEIS E GERAL */
/* ========================================================== */
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--radius-xl: 22px;
--radius-lg: 16px;
--radius-md: 12px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
--swap-bg: rgba(227, 61, 207, 0.12);
--swap-text: #E33DCF;
--same-bg: rgba(3, 15, 170, 0.08);
--same-text: #030FAA;
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
/* ========================================================== */
/* LAYOUT PRINCIPAL */
/* ========================================================== */
.mureg-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: .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-mureg {
width: 100%;
max-width: 1180px;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 200px;
}
.mureg-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
display: flex; flex-direction: column;
max-height: calc(100vh - 80px);
&::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;
}
}
.mureg-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: 768px) { grid-template-columns: 1fr; text-align: center; gap: 16px; .title-badge { justify-self: center; margin-bottom: 8px; } .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.78);
border: 1px solid rgba(227, 61, 207, 0.22); backdrop-filter: blur(10px);
color: var(--text); font-size: 13px; font-weight: 800;
i { color: var(--brand); }
}
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions { justify-self: end; }
/* Buttons */
.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); }
&:disabled { opacity: 0.7; cursor: not-allowed; transform: none; }
}
.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; }
}
/* KPIs */
.mureg-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;
}
}
.kpi {
background: rgba(255,255,255,0.7);
border: 1px solid rgba(17,18,20,0.08);
border-radius: 16px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(8px);
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1);
background: #fff;
border-color: var(--brand);
}
.lbl {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
&.text-success { color: var(--success-text) !important; }
&.text-brand { color: var(--brand) !important; }
}
.val {
font-size: 1.25rem;
font-weight: 950;
color: var(--text);
&.text-success { color: var(--success-text) !important; }
&.text-brand { color: var(--brand) !important; }
}
}
/* Controls */
.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; } }
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass {
appearance: none; -webkit-appearance: none; -moz-appearance: none;
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; }
.select-wrapper:hover .select-icon { color: var(--blue); }
/* Animation */
.animate-fade-in { animation: simpleFadeIn 0.5s ease-out forwards; }
@keyframes simpleFadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
/* BODY & GROUPS */
.mureg-body { padding: 0; background: transparent; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.groups-container { padding: 16px; overflow-y: auto; height: 100%; }
.group-list { display: flex; flex-direction: column; gap: 12px; }
.empty-group { background: rgba(255,255,255,0.7); border: 1px dashed rgba(17,18,20,0.12); border-radius: 16px; padding: 18px; text-align: center; font-weight: 800; color: var(--muted); }
.client-group-card {
background: #fff; border-radius: 16px; border: 1px solid rgba(17,18,20,0.08); overflow: hidden; transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227,61,207,0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227,61,207,0.12); }
}
.group-header { padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background: linear-gradient(180deg, #fff, #fdfdfd); &:hover .group-toggle-icon { color: var(--brand); } }
.group-info { display: flex; flex-direction: column; gap: 6px; }
.group-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase;
&.total { background: rgba(3,15,170,0.1); color: var(--blue); }
&.swap { background: var(--swap-bg); color: var(--swap-text); }
&.ok { background: var(--success-bg); color: var(--success-text); }
&.warn { background: var(--warn-bg); color: var(--warn-text); }
}
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
.client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.chip-muted { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); border: 1px solid rgba(17,18,20,0.06); }
.inner-table-wrap { max-height: 450px; overflow-y: auto; }
/* TABLE */
.table-wrap { overflow-x: auto; overflow-y: auto; height: 100%; }
.table-modern {
width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0;
thead th { position: sticky; top: 0; z-index: 10; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px; color: rgba(17, 18, 20, 0.7); font-size: 0.8rem; font-weight: 950; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; cursor: pointer; transition: color 0.2s; text-align: center !important; &:hover { color: var(--brand); } }
tbody tr { transition: background-color 0.2s; border-bottom: 1px solid rgba(17,18,20,0.05); &:hover { background-color: rgba(227, 61, 207, 0.05); } td { border-bottom: 1px solid rgba(17,18,20,0.04); } }
td { padding: 12px; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.875rem; color: var(--text); text-align: center !important; }
}
.row-clickable { cursor: default; }
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.fw-black { font-weight: 950; }
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
.empty-state { background: rgba(255,255,255,0.4); }
.status-pill { display: inline-block; vertical-align: middle; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-weight: 950; font-size: 0.75rem; letter-spacing: 0.3px; text-align: center; text-transform: uppercase; border: 1px solid rgba(17,18,20,0.1); background: rgba(17,18,20,0.05); color: rgba(17,18,20,0.7);
&.is-swap { background: var(--swap-bg); border-color: rgba(227,61,207,0.25); color: var(--swap-text); }
&.is-same { background: var(--same-bg); border-color: rgba(3,15,170,0.25); color: var(--same-text); }
}
/* ACTIONS */
.action-group { display: flex; justify-content: center; gap: 6px; }
.btn-icon {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer;
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
}
/* FOOTER */
.mureg-footer { padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; flex-shrink: 0; @media (max-width: 768px) { justify-content: center; text-align: center; } }
.pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: rgba(255,255,255,0.6); margin: 0 2px; &:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); } }
.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; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px;
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); } /* Adicionado */
}
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } }
}
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
/* FORM & DETAILS */
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
div.box-body { padding: 16px; }
/* EDIT FORM STYLES */
.form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
}
.form-field {
display: flex; flex-direction: column; gap: 6px;
label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); }
&.span-2 { grid-column: span 2; @media (max-width: 600px) { grid-column: span 1; } }
}
.form-control {
border-radius: 8px; border: 1px solid rgba(17,18,20,0.15);
&:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; }
}

View File

@ -0,0 +1,433 @@
import {
Component,
ElementRef,
ViewChild,
Inject,
PLATFORM_ID,
AfterViewInit,
ChangeDetectorRef
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
HttpClient,
HttpClientModule,
HttpParams
} from '@angular/common/http';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
interface MuregRow {
id: string;
item: string;
linhaAntiga: string;
linhaNova: string;
iccid: string;
dataDaMureg: string;
cliente: string;
raw: any;
}
interface ApiPagedResult<T> {
page?: number;
pageSize?: number;
total?: number;
items?: T[];
}
interface ClientGroup {
cliente: string;
total: number;
trocas: number;
comIccid: number;
semIccid: number;
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
templateUrl: './mureg.html',
styleUrls: ['./mureg.scss']
})
export class Mureg implements AfterViewInit {
toastMessage = '';
loading = false;
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef
) {}
private readonly apiBase = 'https://localhost:7205/api/mureg';
// ====== DATA ======
clientGroups: ClientGroup[] = [];
pagedClientGroups: ClientGroup[] = [];
expandedGroup: string | null = null;
groupRows: MuregRow[] = [];
private rowsByClient = new Map<string, MuregRow[]>();
// KPIs
groupLoadedRecords = 0;
groupTotalTrocas = 0;
groupTotalIccids = 0;
// ====== FILTERS & PAGINATION ======
searchTerm = '';
private searchTimer: any = null;
page = 1;
pageSize = 10;
total = 0;
// ====== EDIT MODAL ======
editOpen = false;
editSaving = false;
editModel: any = null;
// ====== CREATE MODAL ======
createOpen = false;
createSaving = false;
createModel: any = {
cliente: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: ''
};
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => { this.refresh(); });
}
private initAnimations() {
document.documentElement.classList.add('js-animate');
setTimeout(() => {
const items = document.querySelectorAll<HTMLElement>('[data-animate]');
items.forEach((el) => el.classList.add('is-visible'));
}, 100);
}
refresh() {
this.page = 1;
this.loadForGroups();
}
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.loadForGroups();
}, 300);
}
clearSearch() {
this.searchTerm = '';
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.loadForGroups();
}
onPageSizeChange() {
this.page = 1;
this.applyPagination();
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.applyPagination();
}
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
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;
}
get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageEnd() {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
trackById(_: number, row: MuregRow) { return row.id; }
// =======================================================================
// LOAD LOGIC
// =======================================================================
private loadForGroups() {
this.loading = true;
const MAX_FETCH = 5000;
let params = new HttpParams()
.set('page', '1')
.set('pageSize', String(MAX_FETCH))
.set('search', (this.searchTerm ?? '').trim())
.set('sortBy', 'cliente')
.set('sortDir', 'asc');
this.http.get<ApiPagedResult<any> | any[]>(this.apiBase, { params }).subscribe({
next: (res: any) => {
const items = Array.isArray(res) ? res : (res.items ?? []);
const normalized = (items ?? []).map((x: any, idx: number) => this.normalizeRow(x, idx));
this.buildGroups(normalized);
this.applyPagination();
this.loading = false;
this.cdr.detectChanges();
},
error: async () => {
this.loading = false;
await this.showToast('Erro ao carregar MUREG.');
}
});
}
private buildGroups(all: MuregRow[]) {
this.rowsByClient.clear();
const safeClient = (c: any) => (String(c ?? '').trim() || 'SEM CLIENTE');
for (const r of all) {
const key = safeClient(r.cliente);
r.cliente = key;
const arr = this.rowsByClient.get(key) ?? [];
arr.push(r);
this.rowsByClient.set(key, arr);
}
const groups: ClientGroup[] = [];
let trocasTotal = 0;
let iccidsTotal = 0;
this.rowsByClient.forEach((arr, cliente) => {
const total = arr.length;
const trocas = arr.filter(x => this.isTroca(x)).length;
const comIccid = arr.filter(x => String(x.iccid ?? '').trim() !== '').length;
const semIccid = total - comIccid;
trocasTotal += trocas;
iccidsTotal += comIccid;
groups.push({ cliente, total, trocas, comIccid, semIccid });
});
groups.sort((a, b) => a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' }));
this.clientGroups = groups;
this.total = groups.length;
this.groupLoadedRecords = all.length;
this.groupTotalTrocas = trocasTotal;
this.groupTotalIccids = iccidsTotal;
}
private applyPagination() {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
this.pagedClientGroups = this.clientGroups.slice(start, end);
if (this.expandedGroup && !this.pagedClientGroups.some(g => g.cliente === this.expandedGroup)) {
this.expandedGroup = null;
this.groupRows = [];
}
}
toggleGroup(cliente: string) {
if (this.expandedGroup === cliente) {
this.expandedGroup = null;
this.groupRows = [];
return;
}
this.expandedGroup = cliente;
const rows = this.rowsByClient.get(cliente) ?? [];
this.groupRows = [...rows].sort((a, b) => {
const ai = parseInt(String(a.item ?? '0'), 10);
const bi = parseInt(String(b.item ?? '0'), 10);
if (Number.isFinite(ai) && Number.isFinite(bi) && ai !== bi) return ai - bi;
return String(a.linhaNova ?? '').localeCompare(String(b.linhaNova ?? ''), 'pt-BR', { sensitivity: 'base' });
});
}
isTroca(r: MuregRow): boolean {
const a = String(r.linhaAntiga ?? '').trim();
const b = String(r.linhaNova ?? '').trim();
if (!a || !b) return false;
return a !== b;
}
private normalizeRow(x: any, idx: number): MuregRow {
const pick = (obj: any, keys: string[]): any => {
for (const k of keys) {
if (obj && obj[k] !== undefined && obj[k] !== null && String(obj[k]).trim() !== '') return obj[k];
}
return '';
};
const item = pick(x, ['item', 'ITEM', 'ITÉM']);
const linhaAntiga = pick(x, ['linhaAntiga', 'linha_antiga', 'LINHA ANTIGA']);
const linhaNova = pick(x, ['linhaNova', 'linha_nova', 'LINHA NOVA']);
const iccid = pick(x, ['iccid', 'ICCID']);
const dataDaMureg = pick(x, ['dataDaMureg', 'data_da_mureg', 'DATA DA MUREG']);
const cliente = pick(x, ['cliente', 'CLIENTE']);
const id = String(pick(x, ['id', 'ID']) || `${idx}-${item}-${linhaNova}-${iccid}`);
return {
id,
item: String(item ?? ''),
linhaAntiga: String(linhaAntiga ?? ''),
linhaNova: String(linhaNova ?? ''),
iccid: String(iccid ?? ''),
dataDaMureg: String(dataDaMureg ?? ''),
cliente: String(cliente ?? ''),
raw: x
};
}
// ====== MODAL EDIÇÃO ======
// 1. Abrir modal
onEditar(r: MuregRow) {
this.editOpen = true;
this.editSaving = false;
this.editModel = {
id: r.id,
item: r.item,
linhaAntiga: r.linhaAntiga,
linhaNova: r.linhaNova,
iccid: r.iccid,
cliente: r.cliente,
dataDaMureg: this.isoToDateInput(r.dataDaMureg)
};
}
// 2. Fechar modal
closeEdit() {
this.editOpen = false;
this.editModel = null;
this.editSaving = false;
}
// 3. Salvar (PUT)
saveEdit() {
if(!this.editModel || !this.editModel.id) return;
this.editSaving = true;
const payload = {
...this.editModel,
dataDaMureg: this.dateInputToIso(this.editModel.dataDaMureg)
};
this.http.put(`${this.apiBase}/${this.editModel.id}`, payload).subscribe({
next: async () => {
this.editSaving = false;
await this.showToast('Registro atualizado com sucesso!');
this.closeEdit();
const currentGroup = this.expandedGroup;
this.loadForGroups();
if(currentGroup) setTimeout(() => this.expandedGroup = currentGroup, 400);
},
error: async () => {
this.editSaving = false;
await this.showToast('Erro ao salvar edição.');
}
});
}
// ====== MODAL CRIAÇÃO ======
onCreate() {
this.createOpen = true;
this.createSaving = false;
this.createModel = {
cliente: '',
item: '',
linhaAntiga: '',
linhaNova: '',
iccid: '',
dataDaMureg: ''
};
}
closeCreate() {
this.createOpen = false;
}
saveCreate() {
if(!this.createModel.cliente || !this.createModel.linhaNova) {
this.showToast('Preencha Cliente e Linha Nova.');
return;
}
this.createSaving = true;
const payload = {
...this.createModel,
dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg)
};
this.http.post(this.apiBase, payload).subscribe({
next: async () => {
this.createSaving = false;
await this.showToast('Mureg criada com sucesso!');
this.closeCreate();
this.loadForGroups();
},
error: async () => {
this.createSaving = false;
await this.showToast('Erro ao criar Mureg.');
}
});
}
// Helpers de Data
private isoToDateInput(iso: string | null | undefined): string {
if(!iso) return '';
const dt = new Date(iso);
if(Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0,10);
}
private dateInputToIso(val: string | null | undefined): string | null {
if(!val) return null;
const dt = new Date(val);
if(Number.isNaN(dt.getTime())) return null;
return dt.toISOString();
}
displayValue(key: MuregKey, v: any): string {
if (v === null || v === undefined || String(v).trim() === '') return '-';
if (key === 'dataDaMureg') {
const s = String(v).trim();
const d = new Date(s);
if (!Number.isNaN(d.getTime())) {
return new Intl.DateTimeFormat('pt-BR').format(d);
}
return s;
}
return String(v);
}
private async showToast(message: string) {
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);
}
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { Billing } from './billing';
describe('Billing', () => {
let service: Billing;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Billing);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,91 @@
// src/app/services/billing.ts
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
export type BillingSortBy =
| 'item'
| 'cliente'
| 'qtdlinhas'
| 'lucro'
| 'valorcontratovivo'
| 'valorcontratoline'
| 'franquiavivo'
| 'franquialine';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface BillingItem {
id: string;
tipo: TipoCliente;
item: number;
cliente: string | null;
qtdLinhas: number | null;
franquiaVivo: number | null;
valorContratoVivo: number | null;
franquiaLine: number | null;
valorContratoLine: number | null;
lucro: number | null;
aparelho: string | null;
formaPagamento: string | null;
}
export interface BillingQuery {
tipo: TipoCliente;
search?: string;
client?: string;
page: number;
pageSize: number;
sortBy?: BillingSortBy;
sortDir?: SortDir;
}
@Injectable({ providedIn: 'root' })
export class BillingService {
// ✅ Use HTTPS pra evitar redirect no preflight (CORS)
// Mesmas portas que você mostrou no log:
// https://localhost:7205
// http://localhost:5298
private baseUrl = 'https://localhost:7205/api/billing';
constructor(private http: HttpClient) {}
getPaged(q: BillingQuery): Observable<PagedResult<BillingItem>> {
const sortBy: BillingSortBy = (q.sortBy ?? 'cliente');
const sortDir: SortDir = (q.sortDir ?? 'asc');
let params = new HttpParams()
.set('tipo', q.tipo)
.set('page', String(q.page))
.set('pageSize', String(q.pageSize))
.set('sortBy', sortBy)
.set('sortDir', sortDir);
const search = (q.search ?? '').trim();
if (search) params = params.set('search', search);
const client = (q.client ?? '').trim();
if (client) params = params.set('client', client);
return this.http.get<PagedResult<BillingItem>>(this.baseUrl, { params });
}
getClients(tipo: TipoCliente): Observable<string[]> {
const params = new HttpParams().set('tipo', tipo);
return this.http.get<string[]>(`${this.baseUrl}/clients`, { params });
}
}

View File

@ -51,17 +51,18 @@ export interface MobileLineDetail extends MobileLineList {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LinesService { export class LinesService {
// ajuste aqui conforme sua API (mesmo host do auth) // ✅ Mesma base do Swagger (evita redirect no preflight/CORS)
private baseUrl = 'http://localhost:5000/api/lines'; private baseUrl = 'https://localhost:7205/api/lines';
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getLines(page: number, pageSize: number, search: string): Observable<PagedResult<MobileLineList>> { getLines(page: number, pageSize: number, search: string): Observable<PagedResult<MobileLineList>> {
let params = new HttpParams() let params = new HttpParams()
.set('page', page) .set('page', String(page))
.set('pageSize', pageSize); .set('pageSize', String(pageSize));
if (search?.trim()) params = params.set('search', search.trim()); const s = (search ?? '').trim();
if (s) params = params.set('search', s);
return this.http.get<PagedResult<MobileLineList>>(this.baseUrl, { params }); return this.http.get<PagedResult<MobileLineList>>(this.baseUrl, { params });
} }
@ -83,4 +84,26 @@ export class LinesService {
form.append('file', file); form.append('file', file);
return this.http.post<{ imported: number }>(`${this.baseUrl}/import-excel`, form); return this.http.post<{ imported: number }>(`${this.baseUrl}/import-excel`, form);
} }
// (opcional) se você usa groups/clients no Geral, pode manter aqui também:
getClients(skil?: string): Observable<string[]> {
let params = new HttpParams();
const s = (skil ?? '').trim();
if (s) params = params.set('skil', s);
return this.http.get<string[]>(`${this.baseUrl}/clients`, { params });
}
getGroups(page: number, pageSize: number, skil?: string, search?: string) {
let params = new HttpParams()
.set('page', String(page))
.set('pageSize', String(pageSize));
const sk = (skil ?? '').trim();
if (sk) params = params.set('skil', sk);
const se = (search ?? '').trim();
if (se) params = params.set('search', se);
return this.http.get<PagedResult<any>>(`${this.baseUrl}/groups`, { params });
}
} }