diff --git a/package-lock.json b/package-lock.json index 2b20021..5df8186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", "express": "^5.1.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -1896,6 +1897,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz", @@ -4299,6 +4306,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/package.json b/package.json index 403a9f7..1484e44 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", "express": "^5.1.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 911db7e..179b507 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,6 +8,13 @@ import { Mureg } from './pages/mureg/mureg'; import { Faturamento } from './pages/faturamento/faturamento'; import { authGuard } from './guards/auth.guard'; +import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; +import { VigenciaComponent } from './pages/vigencia/vigencia'; +import { TrocaNumero } from './pages/troca-numero/troca-numero'; +import { Parcelamento } from './pages/parcelamento/parcelamento'; + +// ✅ NOVO: TROCA DE NÚMERO + export const routes: Routes = [ { path: '', component: Home }, @@ -17,6 +24,13 @@ export const routes: Routes = [ { path: 'geral', component: Geral, canActivate: [authGuard] }, { path: 'mureg', component: Mureg, canActivate: [authGuard] }, { path: 'faturamento', component: Faturamento, canActivate: [authGuard] }, + { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] }, + { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] }, + + // ✅ NOVO: rota da página Troca de Número + { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] }, + + { path: 'parcelamento', component: Parcelamento, canActivate: [authGuard] }, { path: '**', redirectTo: '' }, ]; diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index c3bf37f..c3693c2 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -91,15 +91,30 @@ Gerenciar Linhas - + Faturamento + + + Vigência + + Mureg + + + Troca de Número + + + + + Parcelamento + + Controle de Contratos @@ -108,6 +123,11 @@ Gerenciar Clientes + + + Dados dos Usuários + + Relatórios diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 4d38fa7..2739aca 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -20,21 +20,35 @@ export class Header { // ✅ define quando mostrar header “logado” isLoggedHeader = false; + // ✅ rotas internas que usam menu lateral + private readonly loggedPrefixes = [ + '/geral', + '/mureg', + '/faturamento', + '/dadosusuarios', + '/vigencia', + '/trocanumero', + '/parcelamento', // ✅ ADICIONADO: Parcelamento + ]; + constructor( private router: Router, @Inject(PLATFORM_ID) private platformId: object ) { this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { - const url = event.urlAfterRedirects || event.url; + const rawUrl = event.urlAfterRedirects || event.url; + + // normaliza (remove query/hash) + const url = rawUrl.split('?')[0].split('#')[0]; this.isHome = (url === '/' || url === ''); - // ✅ considera header logado quando está em rotas internas - // (agora inclui MUREG) - this.isLoggedHeader = - url.startsWith('/geral') || - url.startsWith('/mureg'); + // ✅ considera "logado" se a rota começa com qualquer prefixo interno + // aceita também subrotas, ex: /parcelamento/detalhes/123 + this.isLoggedHeader = this.loggedPrefixes.some((p) => + url === p || url.startsWith(p + '/') + ); // ✅ ao trocar de rota, fecha o menu this.menuOpen = false; diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html new file mode 100644 index 0000000..cc9f1b7 --- /dev/null +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -0,0 +1,209 @@ +
+ +
+ +
+ + + + + +
+
+ +
+
+
+ DADOS USUÁRIOS +
+
+
GESTÃO DE USUÁRIOS
+ Base de dados agrupada por cliente +
+
+ +
+
+ +
+
+ Total Usuários + + + {{ kpiTotalRegistros || 0 }} + +
+
+ Clientes Únicos + + + {{ kpiClientesUnicos || 0 }} + +
+
+ Com CPF + + + {{ kpiComCpf || 0 }} + +
+
+ Com E-mail + + + {{ kpiComEmail || 0 }} + +
+
+ +
+
+ + + +
+ +
+ Itens por pág: +
+ + +
+
+
+
+ +
+
+
+ +
+ Nenhum cliente encontrado. +
+ +
+
+ +
+
+
{{ g.cliente }}
+
+ {{ g.totalRegistros }} Registros + {{ g.comCpf }} CPF + {{ g.comEmail }} Email +
+
+
+
+ +
+
+ Registros do Cliente + Visualização detalhada +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
ITEMLINHACPFE-MAILCELULARAÇÕES
Nenhum registro encontrado.
{{ r.item }}{{ r.linha }}{{ r.cpf || '-' }}{{ r.email || '-' }}{{ r.celular || '-' }} +
+ +
+
+
+
+ +
+
+
+
+ + + +
+
+
+ + + \ No newline at end of file diff --git a/src/app/pages/dados-usuarios/dados-usuarios.scss b/src/app/pages/dados-usuarios/dados-usuarios.scss new file mode 100644 index 0000000..968cac0 --- /dev/null +++ b/src/app/pages/dados-usuarios/dados-usuarios.scss @@ -0,0 +1,272 @@ +/* ========================================================== */ +/* 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; + --danger-bg: rgba(220, 53, 69, 0.1); + --danger-text: #dc3545; + + --radius-xl: 22px; + --radius-lg: 16px; + --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); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); + box-sizing: border-box; +} + +/* LAYOUT PRINCIPAL */ +.users-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-geral-responsive { + width: 100%; + max-width: 1180px; /* Igual ao Mureg */ + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 200px; +} + +.geral-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; + 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; + } +} + +/* HEADER */ +.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: 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); } +} +.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 (ESTILO MUREG) */ +.users-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; } } +} + +.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-wrapper { position: relative; display: inline-block; min-width: 90px; } +.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 */ +.geral-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); } + &.ok { background: var(--success-bg); color: var(--success-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; } +} +.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); } + +.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 */ +.geral-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); } } .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; } +.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; } } +.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; } } \ No newline at end of file diff --git a/src/app/pages/dados-usuarios/dados-usuarios.spec.ts b/src/app/pages/dados-usuarios/dados-usuarios.spec.ts new file mode 100644 index 0000000..a079053 --- /dev/null +++ b/src/app/pages/dados-usuarios/dados-usuarios.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DadosUsuarios } from './dados-usuarios'; + +describe('DadosUsuarios', () => { + let component: DadosUsuarios; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DadosUsuarios], // standalone component + }).compileComponents(); + + fixture = TestBed.createComponent(DadosUsuarios); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts new file mode 100644 index 0000000..c236b2c --- /dev/null +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -0,0 +1,229 @@ +import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; + +import { + DadosUsuariosService, + UserDataClientGroup, + UserDataRow, + UserDataGroupResponse, + PagedResult +} from '../../services/dados-usuarios.service'; + +type ViewMode = 'lines' | 'groups'; + +@Component({ + selector: 'app-dados-usuarios', + standalone: true, + imports: [CommonModule, FormsModule, HttpClientModule], + templateUrl: './dados-usuarios.html', + styleUrls: ['./dados-usuarios.scss'], + providers: [DadosUsuariosService] +}) +export class DadosUsuarios implements OnInit { + + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + loading = false; + errorMsg = ''; + + // Filtros + search = ''; + + // Paginação + page = 1; + pageSize = 10; + total = 0; + + // Ordenação + sortBy = 'cliente'; + sortDir: 'asc' | 'desc' = 'asc'; + + // PADRÃO: GROUPS (Acordeão) + viewMode: ViewMode = 'groups'; + + // Dados + groups: UserDataClientGroup[] = []; + rows: UserDataRow[] = []; + + // KPIs + kpiTotalRegistros = 0; + kpiClientesUnicos = 0; + kpiComCpf = 0; + kpiComEmail = 0; + + // ACORDEÃO + expandedGroup: string | null = null; + expandedLoading = false; + groupRows: UserDataRow[] = []; + + // Modal / Toast + detailsOpen = false; + selectedRow: UserDataRow | null = null; + toastOpen = false; + toastMessage = ''; + toastType: 'success' | 'danger' = 'success'; + private toastTimer: any = null; + private searchTimer: any = null; + + constructor(private service: DadosUsuariosService) {} + + ngOnInit(): void { + this.fetch(1); + } + + // Alternar Visualização + setView(mode: ViewMode): void { + if (this.viewMode === mode) return; + this.viewMode = mode; + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + this.sortBy = mode === 'groups' ? 'cliente' : 'item'; + this.fetch(1); + } + + get totalPages(): number { + return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + } + + get pageStart(): number { return (this.page - 1) * this.pageSize + 1; } + + get pageEnd(): number { + const end = this.page * this.pageSize; + return end > this.total ? this.total : end; + } + + 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; + } + + fetch(goToPage?: number): void { + if (goToPage) this.page = goToPage; + this.loading = true; + + if(goToPage && goToPage !== this.page) this.expandedGroup = null; + + if (this.viewMode === 'groups') { + this.fetchGroups(); + } else { + this.fetchLines(); // Fallback se quiser usar + } + } + + refresh() { + this.fetch(1); + } + + private fetchGroups() { + this.service.getGroups({ + search: this.search?.trim(), + page: this.page, + pageSize: this.pageSize, + sortBy: this.sortBy, + sortDir: this.sortDir, + }).subscribe({ + next: (res: UserDataGroupResponse) => { + this.groups = res.data.items || []; + this.total = res.data.total || 0; + + this.kpiTotalRegistros = res.kpis.totalRegistros; + this.kpiClientesUnicos = res.kpis.clientesUnicos; + this.kpiComCpf = res.kpis.comCpf; + this.kpiComEmail = res.kpis.comEmail; + + this.loading = false; + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + this.showToast('Erro ao carregar dados.', 'danger'); + } + }); + } + + private fetchLines() { + // Implementação opcional para modo lista plana + } + + toggleGroup(g: UserDataClientGroup): void { + if (this.expandedGroup === g.cliente) { + this.expandedGroup = null; + this.groupRows = []; + return; + } + + this.expandedGroup = g.cliente; + this.expandedLoading = true; + this.groupRows = []; + + this.service.getRows({ + client: g.cliente, + page: 1, + pageSize: 200, + sortBy: 'item', + sortDir: 'asc' + }).subscribe({ + next: (res: PagedResult) => { + this.groupRows = res.items || []; + this.expandedLoading = false; + }, + error: (err: HttpErrorResponse) => { + this.showToast('Erro ao carregar usuários do cliente.', 'danger'); + this.expandedLoading = false; + } + }); + } + + onSearch() { + if (this.searchTimer) clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => { + this.page = 1; + this.expandedGroup = null; + this.fetch(); + }, 400); + } + + clearFilters() { this.search = ''; this.fetch(1); } + + onPageSizeChange() { + this.page = 1; + this.fetch(); + } + + goToPage(p: number) { + this.page = p; + this.fetch(); + } + + openDetails(row: UserDataRow) { + this.service.getById(row.id).subscribe({ + next: (fullData: UserDataRow) => { + this.selectedRow = fullData; + this.detailsOpen = true; + }, + error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger') + }); + } + + closeDetails() { this.detailsOpen = false; } + + trackById(_: number, row: UserDataRow) { return row.id; } + trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; } + + showToast(msg: string, type: 'success' | 'danger') { + this.toastMessage = msg; this.toastType = type; this.toastOpen = true; + if(this.toastTimer) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => this.toastOpen = false, 3000); + } + + hideToast() { this.toastOpen = false; } +} \ No newline at end of file diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 67a4d45..0beb345 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -1,121 +1,189 @@ -
+ + + +
+ +
+ +
-
-
+
+
- -
+
- Financeiro + Faturamento
Faturamento
- Visualize dados de Pessoa Física e Pessoa Jurídica + Totais, lucro e comparativo Vivo x Line
-
- -
+
- +
-
- + + -
- -
-
- - -
- + +
+ +
+ + + + + +
- + +
+
+ Total Clientes + + + {{ kpiTotalClientes || 0 }} + +
+ +
+ Total Linhas + + + {{ kpiTotalLinhas || 0 }} + +
+ +
+ Total Vivo + + + {{ formatMoney(kpiTotalVivo) }} + +
+ +
+ Total Line + + + {{ formatMoney(kpiTotalLine) }} + +
+ +
+ Lucro + + + {{ formatMoney(kpiLucro) }} + +
+
+ +
- + + placeholder="Pesquisar por cliente, aparelho, forma de pagamento..." + [(ngModel)]="searchTerm" + (ngModelChange)="onSearch()" /> -
- - Itens por pág: + + Clientes por pág:
- @@ -125,164 +193,339 @@
+
+ + +
+
+
+ +
+ +
+ Nenhum dado encontrado. +
+ +
+
+ +
+
+
{{ g.cliente }}
+ +
+ {{ g.total }} Registros + {{ g.linhas }} Linhas + {{ formatMoney(g.totalVivo) }} + {{ formatMoney(g.totalLine) }} + {{ formatMoney(g.lucro) }} +
+
+ +
+
+ +
+
+ Registros do Cliente + Clique no “olho” para ver todos os detalhes +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
ITEM {{ sortBy==='item' && sortDir==='desc' ? '▼' : '▲' }}
+
+
QTD LINHAS {{ sortBy==='qtdlinhas' && sortDir==='desc' ? '▼' : '▲' }}
+
VIVOLINE MÓVELAÇÕES
+
FRANQUIA {{ sortBy==='franquiavivo' && sortDir==='desc' ? '▼' : '▲' }}
+
+
VALOR (R$) {{ sortBy==='valorcontratovivo' && sortDir==='desc' ? '▼' : '▲' }}
+
+
FRANQUIA {{ sortBy==='franquialine' && sortDir==='desc' ? '▼' : '▲' }}
+
+
VALOR (R$) {{ sortBy==='valorcontratoline' && sortDir==='desc' ? '▼' : '▲' }}
+
Nenhum registro.
{{ r.item }}{{ r.qtdLinhas ?? 0 }}{{ formatFranquia(r.franquiaVivo) }}{{ formatMoney(r.valorContratoVivo) }}{{ formatFranquia(r.franquiaLine) }}{{ formatMoney(r.valorContratoLine) }} +
+ + +
+
+
+
+ +
+
- -
- - {{ errorMessage }}
- -
-
- - - Total: {{ result.total }} - - - - - Página: {{ page }} / {{ totalPages }} - - - - Carregando... - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- ITEM - - - -
-
-
- CLIENTE - - - -
-
-
- QTD LINHAS - - - -
-
-
- FRANQUIA VIVO - - - -
-
-
- CONTRATO VIVO - - - -
-
-
- FRANQUIA LINE - - - -
-
-
- CONTRATO LINE - - - -
-
-
- LUCRO - - - -
-
- APARELHO - - PAGAMENTO -
- - Nenhum registro encontrado. -
{{ it.item }}{{ it.cliente }}{{ it.qtdLinhas ?? 0 }}{{ brl(it.franquiaVivo) }}{{ brl(it.valorContratoVivo) }}{{ brl(it.franquiaLine) }}{{ brl(it.valorContratoLine) }}{{ brl(it.lucro) }}{{ it.aparelho || '-' }}{{ it.formaPagamento || '-' }}
-
-
- - -
+ + + + + diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index da512f1..4326aa4 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -4,10 +4,19 @@ --text: #111214; --muted: rgba(17, 18, 20, 0.65); - --radius-xl: 22px; - --radius-lg: 16px; - --radius-md: 12px; + --bg-vivo: #fbf5fc; + --text-vivo: #8a2be2; + --bg-line: #f5f6ff; + --text-line: #030FAA; + + --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; --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); @@ -21,8 +30,11 @@ box-sizing: border-box; } -/* PAGE BG igual Geral */ -.billing-page { +/* PAGE BG */ +.fat-page, +.fat-page * { box-sizing: border-box; } + +.fat-page { min-height: 100vh; padding: 0 12px var(--page-bottom-gap); display: flex; @@ -44,7 +56,6 @@ } } -/* BLOBS igual Geral */ .page-blob { position: fixed; pointer-events: none; @@ -62,12 +73,12 @@ } @keyframes floaty { - 0% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(18px, 10px) scale(1.03); } + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(18px, 10px) scale(1.03); } 100% { transform: translate(0, 0) scale(1); } } -.container-billing { +.container-fat { width: 100%; max-width: 1180px; position: relative; @@ -76,8 +87,7 @@ margin-bottom: var(--page-bottom-gap); } -/* CARD glass */ -.billing-card { +.fat-card { border-radius: var(--radius-xl); overflow: hidden; background: var(--glass-bg); @@ -101,8 +111,7 @@ } } -/* HEADER */ -.billing-header { +.fat-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)); @@ -164,24 +173,7 @@ 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 */ .filters-row { display: flex; justify-content: center; @@ -213,10 +205,7 @@ align-items: center; gap: 6px; - &:hover { - color: var(--text); - background: rgba(255, 255, 255, 0.5); - } + &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; @@ -224,24 +213,105 @@ box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } + &:disabled { opacity: 0.5; cursor: not-allowed; } } -/* CLIENTE: select glass + botão limpar */ -.client-filter { +/* CLIENT MULTI SELECT */ +.client-filter-wrap { position: relative; } + +.btn-client-filter { display: flex; align-items: center; gap: 8px; -} - -.btn-clear-client { + 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); } + &.has-selection { background: #fff; border-color: var(--brand); } } -/* CONTROLS igual Geral */ +.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 520px; } + +.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; + user-select: none; +} + +.chip-close { + margin-left: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + width: 14px; + height: 14px; + + &:hover { background: rgba(227, 61, 207, 0.2); color: #b91f9b; } +} + +.client-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + max-height: 430px; + background: #fff; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0,0,0,0.15); + border: 1px solid rgba(17,18,20,0.08); + z-index: 100; + display: flex; + flex-direction: column; + overflow: hidden; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.dropdown-header-search { + padding: 8px; + border-bottom: 1px solid rgba(0,0,0,0.05); + background: #f9fafb; +} + +.dropdown-list { overflow-y: auto; max-height: 310px; } + +.dropdown-item-custom { + padding: 10px 16px; + font-size: 0.85rem; + color: var(--text); + cursor: pointer; + border-bottom: 1px solid rgba(0,0,0,0.03); + + &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } + &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } +} + +.dropdown-footer { padding: 10px; border-top: 1px solid rgba(0,0,0,0.05); background: #fff; } + +/* CONTROLS */ .controls { display: flex; gap: 12px; @@ -251,7 +321,7 @@ } .search-group { - max-width: 320px; + max-width: 360px; border-radius: 12px; overflow: hidden; display: flex; @@ -259,7 +329,6 @@ 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); @@ -275,8 +344,6 @@ padding-right: 8px; display: flex; align-items: center; - - i { font-size: 1rem; } } .form-control { @@ -299,62 +366,28 @@ 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; - } -} +.page-size { margin-left: auto; } +.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); - } + &:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); } + &:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); } } .select-icon { @@ -367,12 +400,63 @@ font-size: 0.75rem; } -.text-brand { color: var(--brand) !important; } -.fw-black { font-weight: 950; } +/* KPIs */ +.fat-kpis { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + width: 100%; + + @media (max-width: 1100px) { grid-template-columns: repeat(3, 1fr); } + @media (max-width: 768px) { 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); + box-shadow: 0 2px 5px rgba(0,0,0,0.02); + min-width: 160px; + + &: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); + white-space: nowrap; + } + + .val { + font-size: 1.12rem; + font-weight: 950; + color: var(--text); + white-space: nowrap; + } +} + +.kpi-wide { + min-width: 220px; + padding: 14px 18px; + .val { font-size: 1.18rem; } +} /* BODY */ -.billing-body { - padding: 16px; +.fat-body { + padding: 0; background: transparent; flex: 1; overflow: hidden; @@ -381,58 +465,114 @@ 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; +/* ========================================================= */ +/* ✅ GROUP VIEW (MUREG STYLE) */ +/* ========================================================= */ +.groups-container { + padding: 16px; + overflow-y: 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; +.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); } + &.lines { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.75); } + &.vivo { background: rgba(227,61,207,0.12); color: var(--brand); } + &.line { background: rgba(3,15,170,0.08); color: var(--blue); } + &.lucro { background: var(--success-bg); color: var(--success-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: 520px; overflow: auto; } + +/* TABLE */ +.table-wrap { overflow: auto; height: 100%; } + .table-modern { width: 100%; - min-width: 1100px; + min-width: 1200px; border-collapse: separate; border-spacing: 0; @@ -445,26 +585,22 @@ border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px; color: rgba(17, 18, 20, 0.7); - font-size: 0.78rem; + font-size: 0.8rem; 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; @@ -472,51 +608,34 @@ color: var(--text); text-align: center !important; } - - .sortable { - cursor: pointer; - user-select: none; - - &:hover { color: var(--brand); } - } } -.th-content { +.th-content { display: flex; align-items: center; justify-content: center; gap: 4px; } +.sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } } +.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; } +.empty-state { background: rgba(255,255,255,0.4); } + +/* 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; - gap: 6px; -} + color: rgba(17,18,20,0.5); + transition: all 0.2s; -.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; + &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } + &.success:hover { color: var(--success-text); background: var(--success-bg); } } /* FOOTER */ -.billing-footer { +.fat-footer { padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; @@ -526,10 +645,7 @@ flex-wrap: wrap; flex-shrink: 0; - @media (max-width: 768px) { - justify-content: center; - text-align: center; - } + @media (max-width: 768px) { justify-content: center; text-align: center; } } .pagination-modern .page-link { @@ -540,27 +656,235 @@ background: rgba(255,255,255,0.6); margin: 0 2px; - &:hover { - transform: translateY(-1px); - border-color: var(--brand); - color: var(--brand); + &: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; +} + +/* UTIL COLORS */ +.text-brand { color: var(--brand) !important; } +.text-vivo { color: var(--text-vivo) !important; } +.text-line { color: var(--blue) !important; } +.fw-black { font-weight: 950; } + +/* MODALS (mantidos do seu arquivo) */ +.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; + width: min(900px, 100%); + max-height: 90vh; + animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@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; + background: rgba(3, 15, 170, 0.1); + color: var(--blue); + display: flex; + align-items: center; + justify-content: center; + + &.success { background: var(--success-bg); color: var(--success-text); } + &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } + } + + .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } } +} + +.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } } +.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } + +/* detalhes e comparativo (mantidos) */ +.details-dashboard { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + + @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } + @media (max-width: 700px) { grid-template-columns: 1fr; } +} + +.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } } + +.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; +} + +.box-header.justify-content-center { + justify-content: center !important; + text-align: center; + background: rgba(227, 61, 207, 0.04); + color: var(--brand); + padding: 8px; + + i { margin-right: 8px; color: var(--brand); } +} + +.box-body { padding: 16px; } + +.info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.info-item { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 8px; + background: rgba(245, 245, 247, 0.5); + border-radius: 12px; + border: 1px solid rgba(0,0,0,0.03); + + &.span-2 { grid-column: span 2; } + + .lbl { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; + color: var(--muted); + margin-bottom: 2px; + white-space: nowrap; + } + + .val { font-size: 0.9rem; font-weight: 700; color: var(--text); word-break: break-word; } +} + +/* Comparativo */ +.finance-dashboard { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + @media(max-width: 700px) { grid-template-columns: 1fr; } +} + +.finance-card { + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + border: 1px solid rgba(0,0,0,0.04); + overflow: hidden; + + &.vivo-card { + border-top: 4px solid var(--brand); + .card-header-f { color: var(--brand); background: var(--bg-vivo); } + } + + &.line-card { + border-top: 4px solid var(--blue); + .card-header-f { color: var(--blue); background: var(--bg-line); } } } -.pagination-modern .page-item.disabled .page-link { - opacity: 0.6; +.card-header-f { padding: 12px 16px; font-weight: 800; font-size: 0.95rem; display: flex; align-items: center; } + +.card-body-f { + padding: 16px; + + .row-item { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + font-size: 0.85rem; + color: var(--muted); + + strong { color: var(--text); font-weight: 700; } + + &.total { + font-size: 1rem; + color: var(--text); + margin-top: 8px; + margin-bottom: 0; + strong { font-weight: 900; } + } + } + + .divider { height: 1px; background: rgba(0,0,0,0.06); margin: 12px 0; } } -/* RESPONSIVO */ -@media (max-width: 992px) { - .table-modern { min-width: 1000px; } +.finance-summary { + background: #fff; + border-radius: 16px; + padding: 16px 24px; + box-shadow: 0 2px 12px rgba(0,0,0,0.03); + display: flex; + align-items: center; + justify-content: space-around; + + .summary-item { + display: flex; + flex-direction: column; + align-items: center; + + .lbl { font-size: 0.75rem; text-transform: uppercase; font-weight: 800; color: var(--muted); } + .val { font-size: 1.05rem; font-weight: 900; } + } + + .vertical-line { width: 1px; height: 40px; background: rgba(0,0,0,0.08); } } +/* Mobile */ @media (max-width: 576px) { - :host { - --page-top-gap: 16px; - --page-bottom-gap: 140px; - } - - .table-wrap-tall { max-height: 70vh !important; } + :host { --page-top-gap: 16px; --page-bottom-gap: 140px; } + .container-fat { max-width: 100%; } + .table-modern { min-width: 980px; } + .inner-table-wrap { max-height: 68vh; } } diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index bb1b9c6..b8ec83e 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -1,208 +1,607 @@ -import { CommonModule } from '@angular/common'; -import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + Inject, + PLATFORM_ID, + AfterViewInit, + ChangeDetectorRef, + OnDestroy, + HostListener +} from '@angular/core'; +import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Subscription } from 'rxjs'; +import { HttpClientModule } from '@angular/common/http'; + import { BillingService, BillingItem, - PagedResult, BillingSortBy, SortDir, - TipoCliente + TipoCliente, + TipoFiltro } from '../../services/billing'; +interface BillingClientGroup { + cliente: string; + total: number; // registros + linhas: number; // soma qtdLinhas + totalVivo: number; + totalLine: number; + lucro: number; +} + @Component({ - selector: 'app-faturamento', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, HttpClientModule], templateUrl: './faturamento.html', - styleUrl: './faturamento.scss', + styleUrls: ['./faturamento.scss'] }) -export class Faturamento implements OnInit, OnDestroy { - // ===== UI state ===== +export class Faturamento implements AfterViewInit, OnDestroy { + toastMessage = ''; + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + @ViewChild('detailModal', { static: false }) detailModal!: ElementRef; + @ViewChild('compareModal', { static: false }) compareModal!: ElementRef; + + constructor( + @Inject(PLATFORM_ID) private platformId: object, + private billing: BillingService, + private cdr: ChangeDetectorRef + ) {} + loading = false; - errorMessage: string | null = null; - // ===== filtros ===== - tipo: TipoCliente = 'PF'; - search = ''; - client = ''; - page = 1; - pageSize = 20; + // filtros + searchTerm = ''; + filterTipo: TipoFiltro = 'ALL'; - // ===== ordenação ===== + clientsList: string[] = []; + selectedClients: string[] = []; + showClientMenu = false; + clientSearchTerm = ''; + + // sort/paging sortBy: BillingSortBy = 'cliente'; sortDir: SortDir = 'asc'; - // ===== dados ===== - result: PagedResult = { page: 1, pageSize: 20, total: 0, items: [] }; - clients: string[] = []; + // pagina por CLIENTES (grupos) + page = 1; + pageSize = 10; + total = 0; // total de grupos - // ===== subs ===== - private sub = new Subscription(); - private searchTimer: ReturnType | null = null; + // agrupamento + clientGroups: BillingClientGroup[] = []; + pagedClientGroups: BillingClientGroup[] = []; + expandedGroup: string | null = null; + groupRows: BillingItem[] = []; + private rowsByClient = new Map(); - @ViewChild('errorToast', { static: false }) errorToast?: ElementRef; + // KPIs + loadingKpis = false; + kpiTotalClientes = 0; + kpiTotalLinhas = 0; + kpiTotalVivo = 0; + kpiTotalLine = 0; + kpiLucro = 0; - constructor(private billingService: BillingService) {} + // modals + detailOpen = false; + compareOpen = false; + detailData: BillingItem | null = null; + compareData: BillingItem | null = null; - ngOnInit(): void { - this.loadClients(); - this.loadData(); + private searchTimer: any = null; + + // cache do ALL + private allCache: BillingItem[] = []; + private allCacheAt = 0; + private allCacheTtlMs = 15000; + + // -------------------------- + // Eventos globais + // -------------------------- + @HostListener('document:click', ['$event']) + onDocumentClick(ev: MouseEvent) { + if (!isPlatformBrowser(this.platformId)) return; + if (this.anyModalOpen()) return; + + if (!this.showClientMenu) return; + const target = ev.target as HTMLElement | null; + if (!target) return; + + const inside = !!target.closest('.client-filter-wrap'); + if (!inside) { + this.showClientMenu = false; + this.cdr.detectChanges(); + } + } + + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(ev: KeyboardEvent) { + if (!isPlatformBrowser(this.platformId)) return; + + if (ev.key === 'Escape') { + if (this.anyModalOpen()) { + ev.preventDefault(); + ev.stopPropagation(); + this.closeAllModals(); + return; + } + + if (this.showClientMenu) { + this.showClientMenu = false; + ev.stopPropagation(); + this.cdr.detectChanges(); + } + } } 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(); + async ngAfterViewInit() { + if (!isPlatformBrowser(this.platformId)) return; + + this.initAnimations(); + + setTimeout(() => { + this.refreshData(true); + }); } - onSearchChange() { + private initAnimations() { + document.documentElement.classList.add('js-animate'); + setTimeout(() => { + const items = document.querySelectorAll('[data-animate]'); + items.forEach((el) => el.classList.add('is-visible')); + }, 100); + } + + // -------------------------- + // Helpers + // -------------------------- + private anyModalOpen(): boolean { + return !!(this.detailOpen || this.compareOpen); + } + + closeAllModals() { + this.detailOpen = false; + this.compareOpen = false; + this.detailData = null; + this.compareData = null; + this.cdr.detectChanges(); + } + + /** ✅ Evita usar Number(...) no template */ + hasLucro(item: BillingItem | null): boolean { + const n = Number((item as any)?.lucro ?? 0); + return !Number.isNaN(n) && n !== 0; + } + + /** ✅ Lê observação com/sem acento sem quebrar template */ + getObservacao(item: BillingItem | null): string { + const anyItem: any = item as any; + const v = + anyItem?.observacao ?? + anyItem?.['observação'] ?? + anyItem?.OBSERVACAO ?? + anyItem?.['OBSERVAÇÃO']; + + const s = (v ?? '').toString().trim(); + return s ? s : '—'; + } + + private normalizeText(s: any): string { + return (s ?? '') + .toString() + .trim() + .toUpperCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + } + + private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean { + if (filtro === 'ALL') return true; + + const t = this.normalizeText(itemTipo); + + if (filtro === 'PF') return t === 'PF' || t.includes('FISICA'); + if (filtro === 'PJ') return t === 'PJ' || t.includes('JURIDICA'); + + return true; + } + + formatMoney(v: any): string { + const n = Number(v); + if (v === null || v === undefined || Number.isNaN(n)) return '—'; + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n); + } + + formatFranquia(v: any): string { + if (v === null || v === undefined) return '—'; + if (typeof v === 'string') { + const s = v.trim(); + if (!s) return '—'; + if (/[A-Z]/i.test(s)) return s; + const n = Number(s.replace(',', '.')); + if (Number.isNaN(n)) return s; + return `${n.toLocaleString('pt-BR')} GB`; + } + + const n = Number(v); + if (Number.isNaN(n)) return '—'; + return `${n.toLocaleString('pt-BR')} GB`; + } + + // -------------------------- + // Filtros / Clientes + // -------------------------- + toggleClientMenu() { + this.showClientMenu = !this.showClientMenu; + } + + closeClientDropdown() { + this.showClientMenu = false; + } + + isClientSelected(client: string): boolean { + return this.selectedClients.includes(client); + } + + get filteredClientsList(): string[] { + const base = this.clientsList ?? []; + if (!this.clientSearchTerm) return base; + const s = this.clientSearchTerm.toLowerCase(); + return base.filter((c) => (c ?? '').toLowerCase().includes(s)); + } + + selectClient(client: string | null) { + if (client === null) { + this.selectedClients = []; + } else { + const idx = this.selectedClients.indexOf(client); + if (idx >= 0) this.selectedClients.splice(idx, 1); + else this.selectedClients.push(client); + } + + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + this.refreshData(); + } + + removeClient(client: string, event: Event) { + event.stopPropagation(); + const idx = this.selectedClients.indexOf(client); + if (idx >= 0) this.selectedClients.splice(idx, 1); + + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + this.refreshData(); + } + + clearClientSelection(event?: Event) { + if (event) event.stopPropagation(); + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + this.refreshData(); + } + + setFilter(type: 'ALL' | TipoCliente) { + if (this.filterTipo === type) return; + + this.filterTipo = type; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + + this.refreshData(true); + } + + // -------------------------- + // Search + // -------------------------- + onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.page = 1; - this.loadData(); - }, 350); + this.expandedGroup = null; + this.groupRows = []; + this.refreshData(); + }, 250); } - applyClientFilter() { + clearSearch() { + this.searchTerm = ''; this.page = 1; - this.loadData(); + this.expandedGroup = null; + this.groupRows = []; + this.refreshData(); } - clearClient() { - this.client = ''; - this.page = 1; - this.loadData(); + // -------------------------- + // Data + // -------------------------- + refreshData(forceReloadAll = false) { + this.loadAllAndApply(forceReloadAll); } - refresh() { - this.loadClients(); - this.loadData(); + private getAllItems(force = false): Promise { + const now = Date.now(); + + if (!force && this.allCache.length > 0 && (now - this.allCacheAt) < this.allCacheTtlMs) { + return Promise.resolve(this.allCache); + } + + return new Promise((resolve) => { + this.billing.getAll().subscribe({ + next: (items) => { + this.allCache = (items ?? []); + this.allCacheAt = Date.now(); + resolve(this.allCache); + }, + error: () => resolve(this.allCache ?? []) + }); + }); } - changePageSize() { - this.page = 1; - this.loadData(); + private rebuildClientsList(baseTipo: BillingItem[]) { + const set = new Set(); + for (const r of baseTipo) { + const c = (r.cliente ?? '').trim(); + if (c) set.add(c); + } + this.clientsList = Array.from(set).sort((a, b) => a.localeCompare(b)); } - // ========================= - // Ordenação - // ========================= - toggleSort(col: BillingSortBy) { - if (this.sortBy === col) { - this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; - } else { - this.sortBy = col; + private buildGroups(items: BillingItem[]) { + this.rowsByClient.clear(); + + const safeClient = (c: any) => (String(c ?? '').trim() || 'SEM CLIENTE'); + + for (const r of (items ?? [])) { + const key = safeClient(r.cliente); + (r as any).cliente = key; + + const arr = this.rowsByClient.get(key) ?? []; + arr.push(r); + this.rowsByClient.set(key, arr); + } + + const groups: BillingClientGroup[] = []; + this.rowsByClient.forEach((arr, cliente) => { + let linhas = 0; + let totalVivo = 0; + let totalLine = 0; + let lucro = 0; + + for (const x of arr) { + linhas += Number(x.qtdLinhas ?? 0) || 0; + totalVivo += Number(x.valorContratoVivo ?? 0) || 0; + totalLine += Number(x.valorContratoLine ?? 0) || 0; + lucro += Number((x as any).lucro ?? 0) || 0; + } + + groups.push({ + cliente, + total: arr.length, + linhas, + totalVivo: Number(totalVivo.toFixed(2)), + totalLine: Number(totalLine.toFixed(2)), + lucro: Number(lucro.toFixed(2)) + }); + }); + + groups.sort((a, b) => a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' })); + + this.clientGroups = groups; + this.total = groups.length; + + this.applyGroupPagination(); + } + + private applyGroupPagination() { + 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 = []; + } + } + + private sortRows(arr: BillingItem[]): BillingItem[] { + const dir = this.sortDir === 'asc' ? 1 : -1; + + const getVal = (r: BillingItem) => { + switch (this.sortBy) { + case 'tipo': return (r.tipo ?? '').toString(); + case 'item': return r.item ?? 0; + case 'cliente': return (r.cliente ?? '').toString(); + case 'qtdlinhas': return r.qtdLinhas ?? 0; + case 'franquiavivo': return r.franquiaVivo ?? 0; + case 'valorcontratovivo': return r.valorContratoVivo ?? 0; + case 'franquialine': return r.franquiaLine ?? 0; + case 'valorcontratoline': return r.valorContratoLine ?? 0; + case 'lucro': return (r as any).lucro ?? 0; + case 'aparelho': return (r.aparelho ?? '').toString(); + case 'formapagamento': return (r.formaPagamento ?? '').toString(); + default: return (r.cliente ?? '').toString(); + } + }; + + return [...(arr ?? [])].sort((a, b) => { + const va = getVal(a); + const vb = getVal(b); + if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; + return String(va).localeCompare(String(vb), 'pt-BR', { sensitivity: 'base' }) * dir; + }); + } + + toggleGroup(cliente: string) { + if (this.expandedGroup === cliente) { + this.expandedGroup = null; + this.groupRows = []; + return; + } + + this.expandedGroup = cliente; + const rows = this.rowsByClient.get(cliente) ?? []; + this.groupRows = this.sortRows(rows); + + this.cdr.detectChanges(); + } + + private applyClientSide(allItems: BillingItem[]) { + const baseTipo = (allItems ?? []).filter((r) => this.matchesTipo(r.tipo, this.filterTipo)); + this.rebuildClientsList(baseTipo); + + let arr = [...baseTipo]; + + if (this.selectedClients.length > 0) { + const set = new Set(this.selectedClients.map((x) => this.normalizeText(x))); + arr = arr.filter((r) => set.has(this.normalizeText(r.cliente))); + } + + const term = (this.searchTerm ?? '').trim().toLowerCase(); + if (term) { + arr = arr.filter((r) => { + const cliente = (r.cliente ?? '').toLowerCase(); + const aparelho = (r.aparelho ?? '').toLowerCase(); + const forma = (r.formaPagamento ?? '').toLowerCase(); + return cliente.includes(term) || aparelho.includes(term) || forma.includes(term); + }); + } + + // KPIs + const unique = new Set(); + let totalLinhas = 0; + let totalVivo = 0; + let totalLine = 0; + let totalLucro = 0; + + for (const r of arr) { + const c = (r.cliente ?? '').trim(); + if (c) unique.add(c); + + totalLinhas += Number(r.qtdLinhas ?? 0) || 0; + totalVivo += Number(r.valorContratoVivo ?? 0) || 0; + totalLine += Number(r.valorContratoLine ?? 0) || 0; + totalLucro += Number((r as any).lucro ?? 0) || 0; + } + + this.kpiTotalClientes = unique.size; + this.kpiTotalLinhas = totalLinhas; + this.kpiTotalVivo = Number(totalVivo.toFixed(2)); + this.kpiTotalLine = Number(totalLine.toFixed(2)); + this.kpiLucro = Number(totalLucro.toFixed(2)); + + this.buildGroups(arr); + } + + private async loadAllAndApply(forceReloadAll = false) { + this.loading = true; + this.loadingKpis = true; + + this.clientGroups = []; + this.pagedClientGroups = []; + this.rowsByClient.clear(); + this.groupRows = []; + + try { + const all = await this.getAllItems(forceReloadAll); + this.applyClientSide(all); + } finally { + this.loading = false; + this.loadingKpis = false; + this.cdr.detectChanges(); + } + } + + // -------------------------- + // Sort / Paging + // -------------------------- + setSort(key: BillingSortBy) { + if (this.sortBy === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; + else { + this.sortBy = key; this.sortDir = 'asc'; } + + if (this.expandedGroup) { + const rows = this.rowsByClient.get(this.expandedGroup) ?? []; + this.groupRows = this.sortRows(rows); + } + + this.cdr.detectChanges(); + } + + onPageSizeChange() { 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))); + this.applyGroupPagination(); + this.cdr.detectChanges(); } 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(); + this.page = Math.max(1, Math.min(this.totalPages, p)); + this.applyGroupPagination(); + this.cdr.detectChanges(); } - prevPage() { - this.goToPage(this.page - 1); + trackById(_: number, row: BillingItem) { + return row.id; } - nextPage() { - this.goToPage(this.page + 1); + trackByCliente(_: number, g: BillingClientGroup) { + return g.cliente; } - // ========================= - // Carregamento - // ========================= - private loadClients() { - this.sub.add( - this.billingService.getClients(this.tipo).subscribe({ - next: (list: string[]) => { - this.clients = list ?? []; - }, - error: () => { - this.clients = []; - }, - }) - ); + get totalPages() { + return Math.ceil((this.total || 0) / this.pageSize) || 1; } - 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) => { - 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; - }, - }) - ); + get pageStart() { + return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } - // ========================= - // 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' }); + get pageEnd() { + return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); } - num(v: number | null | undefined): string { - const n = typeof v === 'number' ? v : 0; - return n.toLocaleString('pt-BR'); + 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; } - trackById(_: number, it: BillingItem) { - return it.id; + // -------------------------- + // Modals + // -------------------------- + onDetalhes(r: BillingItem) { + this.detailOpen = true; + this.detailData = r; + this.cdr.detectChanges(); + } + + onComparativo(r: BillingItem) { + this.compareOpen = true; + this.compareData = r; + this.cdr.detectChanges(); } } diff --git a/src/app/pages/parcelamento/parcelamento.html b/src/app/pages/parcelamento/parcelamento.html new file mode 100644 index 0000000..75cb928 --- /dev/null +++ b/src/app/pages/parcelamento/parcelamento.html @@ -0,0 +1,90 @@ +
+ + + + + + + +
+
+ Total (c/ desconto) + {{ money(kpis.totalComDesconto) }} +
+ +
+ Total (valor cheio) + {{ money(kpis.totalValorCheio) }} +
+ +
+ Desconto total + {{ money(kpis.totalDesconto) }} +
+ +
+ Linhas / Clientes + {{ kpis.linhas }} / {{ kpis.clientes }} + {{ kpis.meses }} meses mapeados +
+
+ + +
+
+
+
Valor por mês
+ Soma mensal do parcelamento +
+
+ +
+
+ +
+
+
Top 10 linhas
+ Linhas com maior soma total +
+
+ +
+
+
+ +
+
+ Carregando... +
+
diff --git a/src/app/pages/parcelamento/parcelamento.scss b/src/app/pages/parcelamento/parcelamento.scss new file mode 100644 index 0000000..86834cd --- /dev/null +++ b/src/app/pages/parcelamento/parcelamento.scss @@ -0,0 +1,151 @@ +:host { + --brand: #E33DCF; + --blue: #030FAA; + --text: #111214; + --muted: rgba(17, 18, 20, 0.65); + + --radius-xl: 22px; + --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); +} + +.parcelamento-page { + position: relative; + padding: clamp(14px, 3vw, 26px); + min-height: calc(100vh - 70px); + color: var(--text); +} + +.page-blob { + position: absolute; + border-radius: 999px; + filter: blur(0.2px); + opacity: 0.20; + pointer-events: none; +} + +.blob-1 { width: 240px; height: 240px; top: 16px; left: -50px; background: var(--brand); } +.blob-2 { width: 280px; height: 280px; top: 140px; right: -80px; background: var(--blue); opacity: .14; } +.blob-3 { width: 220px; height: 220px; bottom: 30px; left: 20%; background: var(--brand); opacity: .10; } + +.glass { + background: var(--glass-bg); + border: var(--glass-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-card); + backdrop-filter: blur(10px); +} + +.page-header { + display: grid; + gap: 14px; + margin-bottom: 14px; + + .title { + h2 { margin: 0; font-weight: 800; letter-spacing: -0.3px; } + p { margin: 2px 0 0; color: var(--muted); } + } + + .filters { + padding: 14px; + .form-label { font-weight: 700; color: rgba(17,18,20,.78); } + } +} + +.kpis { + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 14px; + + @media (max-width: 992px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } + + .kpi-card { + padding: 14px 16px; + + .kpi-label { + display: block; + font-size: 0.9rem; + font-weight: 700; + color: rgba(17,18,20,.70); + } + + .kpi-value { + display: block; + font-size: 1.55rem; + font-weight: 900; + margin-top: 6px; + + small { + font-size: 1rem; + font-weight: 800; + color: rgba(17,18,20,.70); + } + } + + .kpi-sub { + display: block; + margin-top: 6px; + font-size: .86rem; + color: var(--muted); + } + } +} + +.charts { + display: grid; + gap: 14px; + grid-template-columns: 1.2fr 1fr; + + @media (max-width: 992px) { + grid-template-columns: 1fr; + } + + .chart-card { + padding: 14px 16px; + overflow: hidden; + + .chart-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + + h5 { + margin: 0; + font-weight: 900; + letter-spacing: -0.2px; + } + + .muted { + font-size: .9rem; + color: var(--muted); + white-space: nowrap; + } + } + + .chart-area { + height: 360px; + + @media (max-width: 520px) { + height: 320px; + } + } + } +} + +.loading { + margin-top: 14px; + display: flex; + gap: 10px; + align-items: center; + color: var(--muted); +} diff --git a/src/app/pages/parcelamento/parcelamento.ts b/src/app/pages/parcelamento/parcelamento.ts new file mode 100644 index 0000000..88c44c7 --- /dev/null +++ b/src/app/pages/parcelamento/parcelamento.ts @@ -0,0 +1,195 @@ +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { Component, ElementRef, Inject, PLATFORM_ID, ViewChild, AfterViewInit, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import Chart from 'chart.js/auto'; +import { + ParcelamentoService, + ParcelamentoKpis, + ParcelamentoMonthlyPoint, + ParcelamentoTopLine +} from '../../services/parcelamento.service'; + +@Component({ + selector: 'app-parcelamento', + standalone: true, + imports: [CommonModule, FormsModule, HttpClientModule], + templateUrl: './parcelamento.html', + styleUrl: './parcelamento.scss' +}) +export class Parcelamento implements OnInit, AfterViewInit { + @ViewChild('monthlyCanvas') monthlyCanvas!: ElementRef; + @ViewChild('topLinesCanvas') topLinesCanvas!: ElementRef; + + private monthlyChart?: Chart; + private topLinesChart?: Chart; + + loading = false; + + clients: string[] = []; + selectedClient: string = ''; + lineSearch: string = ''; + + kpis: ParcelamentoKpis = { + linhas: 0, + clientes: 0, + totalValorCheio: 0, + totalDesconto: 0, + totalComDesconto: 0, + meses: 0 + }; + + monthlySeries: ParcelamentoMonthlyPoint[] = []; + topLines: ParcelamentoTopLine[] = []; + + constructor( + private parcelamentoService: ParcelamentoService, + @Inject(PLATFORM_ID) private platformId: Object + ) {} + + async ngOnInit() { + await this.loadClients(); + await this.refreshAll(); + } + + ngAfterViewInit() { + if (isPlatformBrowser(this.platformId)) { + this.renderCharts(); + } + } + + private buildOpts() { + const cliente = this.selectedClient?.trim() || undefined; + const linha = this.onlyDigits(this.lineSearch) || undefined; + return { cliente, linha }; + } + + async loadClients() { + try { + this.clients = await firstValueFrom(this.parcelamentoService.getClients()); + } catch { + this.clients = []; + } + } + + async refreshAll() { + this.loading = true; + + try { + const opts = this.buildOpts(); + + this.kpis = await firstValueFrom(this.parcelamentoService.getKpis(opts)); + this.monthlySeries = await firstValueFrom(this.parcelamentoService.getMonthlySeries(opts)); + this.topLines = await firstValueFrom( + this.parcelamentoService.getTopLines({ cliente: opts.cliente, take: 10 }) + ); + + this.renderCharts(); + } finally { + this.loading = false; + } + } + + onClearFilters() { + this.selectedClient = ''; + this.lineSearch = ''; + this.refreshAll(); + } + + onApplyFilters() { + this.refreshAll(); + } + + private renderCharts() { + if (!isPlatformBrowser(this.platformId)) return; + if (!this.monthlyCanvas || !this.topLinesCanvas) return; + + this.renderMonthlyChart(); + this.renderTopLinesChart(); + } + + private renderMonthlyChart() { + const labels = this.monthlySeries.map(x => x.label); + const values = this.monthlySeries.map(x => x.total ?? 0); + + if (this.monthlyChart) this.monthlyChart.destroy(); + + this.monthlyChart = new Chart(this.monthlyCanvas.nativeElement.getContext('2d')!, { + type: 'bar', + data: { + labels, + datasets: [{ label: 'Valor por mês (R$)', data: values }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: true }, + tooltip: { + callbacks: { + label: (ctx) => { + const y = (ctx.parsed as any)?.y; + return ` ${this.money(typeof y === 'number' ? y : 0)}`; + } + } + } + }, + scales: { + y: { + ticks: { + callback: (v: any) => this.money(Number(v) || 0) + } + } + } + } + }); + } + + private renderTopLinesChart() { + const labels = this.topLines.map(x => (x.linha ?? '').toString()); + const values = this.topLines.map(x => x.total ?? 0); + + if (this.topLinesChart) this.topLinesChart.destroy(); + + this.topLinesChart = new Chart(this.topLinesCanvas.nativeElement.getContext('2d')!, { + type: 'bar', + data: { + labels, + datasets: [{ label: 'Top 10 linhas (Total R$)', data: values }] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: true }, + tooltip: { + callbacks: { + label: (ctx) => { + const x = (ctx.parsed as any)?.x; + return ` ${this.money(typeof x === 'number' ? x : 0)}`; + } + } + } + }, + scales: { + x: { + ticks: { + callback: (v: any) => this.money(Number(v) || 0) + } + } + } + } + }); + } + + money(v: number) { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v ?? 0); + } + + private onlyDigits(s: string) { + return (s ?? '').replace(/\D/g, ''); + } +} diff --git a/src/app/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html new file mode 100644 index 0000000..9e30fae --- /dev/null +++ b/src/app/pages/troca-numero/troca-numero.html @@ -0,0 +1,351 @@ +
+ +
+ +
+ + + + + +
+
+ +
+
+
+ TROCA DE NÚMERO +
+ +
+
Troca de Número
+ Registros importados da aba “TROCA DE NÚMERO” +
+ +
+ +
+
+ +
+
+ Motivos + + + {{ total || 0 }} + +
+ +
+ Registros + + + {{ groupLoadedRecords || 0 }} + +
+ +
+ Trocas + + + {{ groupTotalTrocas || 0 }} + +
+ +
+ ICCID + + + {{ groupTotalIccids || 0 }} + +
+
+ +
+
+ + + + + +
+ +
+ Itens por pág: +
+ + +
+
+
+
+ +
+
+
+ +
+ +
+ Nenhum dado encontrado. +
+ +
+
+ +
+
+
{{ g.key }}
+
+ {{ g.total }} Registros + {{ g.trocas }} Trocas + {{ g.comIccid }} ICCID + {{ g.semIccid }} Sem ICCID +
+
+ +
+
+ +
+
+ Registros do Motivo + Use o botão à direita para editar +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ITEMLINHA ANTIGALINHA NOVAICCIDDATA TROCAOBSERVAÇÃOAÇÕES
Nenhum registro.
{{ r.item || '-' }}{{ r.linhaAntiga || '-' }}{{ r.linhaNova || '-' }}{{ r.iccid || '-' }}{{ displayValue('dataTroca', r.dataTroca) }}{{ r.observacao || '-' }} +
+ +
+
+
+
+ +
+
+ +
+
+ + + +
+
+
+ + + + + + + + diff --git a/src/app/pages/troca-numero/troca-numero.scss b/src/app/pages/troca-numero/troca-numero.scss new file mode 100644 index 0000000..1e3832c --- /dev/null +++ b/src/app/pages/troca-numero/troca-numero.scss @@ -0,0 +1,614 @@ +: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; +} + +/* PAGE */ +.troca-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-troca { + width: 100%; + max-width: 1180px; + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 200px; +} + +.troca-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; + } +} + +/* HEADER */ +.troca-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 */ +.troca-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: 380px; + 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; +} + +.select-wrapper:hover .select-icon { color: var(--blue); } + +/* BODY */ +.troca-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; +} + +.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: 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.8rem; + font-weight: 950; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; + 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; + } +} + +.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: 520px; } +.empty-state { background: rgba(255,255,255,0.4); } + +/* 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 */ +.troca-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; + width: min(900px, 100%); + max-height: 90vh; +} + +.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); } + } +} + +.modal-body { padding: 24px; overflow-y: auto; } +.bg-light-gray { background-color: #f8f9fa; } + +.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; } +.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; display: flex; flex-direction: column; } +.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; } +.box-body { padding: 16px; } + +.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; } +} diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts new file mode 100644 index 0000000..f041b1e --- /dev/null +++ b/src/app/pages/troca-numero/troca-numero.ts @@ -0,0 +1,455 @@ +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 TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao'; + +interface TrocaRow { + id: string; + item: string; + linhaAntiga: string; + linhaNova: string; + iccid: string; + dataTroca: string; + motivo: string; + observacao: string; + raw: any; +} + +interface ApiPagedResult { + page?: number; + pageSize?: number; + total?: number; + items?: T[]; +} + +interface GroupItem { + key: string; // aqui é o MOTIVO + total: number; + trocas: number; + comIccid: number; + semIccid: number; +} + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, HttpClientModule], + templateUrl: './troca-numero.html', + styleUrls: ['./troca-numero.scss'] +}) +export class TrocaNumero 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/trocanumero'; + + // ====== DATA ====== + groups: GroupItem[] = []; + pagedGroups: GroupItem[] = []; + expandedGroup: string | null = null; + groupRows: TrocaRow[] = []; + + private rowsByKey = new Map(); + + // 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 = { + item: '', + linhaAntiga: '', + linhaNova: '', + iccid: '', + dataTroca: '', + motivo: '', + observacao: '' + }; + + 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('[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: TrocaRow) { return row.id; } + + // ======================================================================= + // LOAD LOGIC (igual MUREG: puxa bastante e agrupa no front) + // ======================================================================= + 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', 'motivo') + .set('sortDir', 'asc'); + + this.http.get | 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 Troca de Número.'); + } + }); + } + + private buildGroups(all: TrocaRow[]) { + this.rowsByKey.clear(); + + const safeKey = (v: any) => (String(v ?? '').trim() || 'SEM MOTIVO'); + + for (const r of all) { + const key = safeKey(r.motivo); + r.motivo = key; + const arr = this.rowsByKey.get(key) ?? []; + arr.push(r); + this.rowsByKey.set(key, arr); + } + + const groups: GroupItem[] = []; + let trocasTotal = 0; + let iccidsTotal = 0; + + this.rowsByKey.forEach((arr, key) => { + 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({ key, total, trocas, comIccid, semIccid }); + }); + + groups.sort((a, b) => a.key.localeCompare(b.key, 'pt-BR', { sensitivity: 'base' })); + + this.groups = 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.pagedGroups = this.groups.slice(start, end); + + if (this.expandedGroup && !this.pagedGroups.some(g => g.key === this.expandedGroup)) { + this.expandedGroup = null; + this.groupRows = []; + } + } + + toggleGroup(key: string) { + if (this.expandedGroup === key) { + this.expandedGroup = null; + this.groupRows = []; + return; + } + + this.expandedGroup = key; + const rows = this.rowsByKey.get(key) ?? []; + 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: TrocaRow): 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): TrocaRow { + 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 dataTroca = pick(x, ['dataTroca', 'data_troca', 'DATA TROCA', 'DATA DA TROCA']); + const motivo = pick(x, ['motivo', 'MOTIVO']); + const observacao = pick(x, ['observacao', 'OBSERVAÇÃO', 'OBSERVACAO', 'OBSERVACAO']); + + 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 ?? ''), + dataTroca: String(dataTroca ?? ''), + motivo: String(motivo ?? ''), + observacao: String(observacao ?? ''), + raw: x + }; + } + + // ====== MODAL EDIÇÃO ====== + onEditar(r: TrocaRow) { + this.editOpen = true; + this.editSaving = false; + + this.editModel = { + id: r.id, + item: r.item, + linhaAntiga: r.linhaAntiga, + linhaNova: r.linhaNova, + iccid: r.iccid, + motivo: r.motivo, + observacao: r.observacao, + dataTroca: this.isoToDateInput(r.dataTroca) + }; + } + + closeEdit() { + this.editOpen = false; + this.editModel = null; + this.editSaving = false; + } + + saveEdit() { + if (!this.editModel || !this.editModel.id) return; + this.editSaving = true; + + const payload = { + item: this.toNumberOrNull(this.editModel.item), + linhaAntiga: this.editModel.linhaAntiga, + linhaNova: this.editModel.linhaNova, + iccid: this.editModel.iccid, + motivo: this.editModel.motivo, + observacao: this.editModel.observacao, + dataTroca: this.dateInputToIso(this.editModel.dataTroca) + }; + + 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, 350); + }, + 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 = { + item: '', + linhaAntiga: '', + linhaNova: '', + iccid: '', + dataTroca: '', + motivo: '', + observacao: '' + }; + } + + closeCreate() { + this.createOpen = false; + } + + saveCreate() { + this.createSaving = true; + + const payload = { + item: this.toNumberOrNull(this.createModel.item), + linhaAntiga: this.createModel.linhaAntiga, + linhaNova: this.createModel.linhaNova, + iccid: this.createModel.iccid, + motivo: this.createModel.motivo, + observacao: this.createModel.observacao, + dataTroca: this.dateInputToIso(this.createModel.dataTroca) + }; + + this.http.post(this.apiBase, payload).subscribe({ + next: async () => { + this.createSaving = false; + await this.showToast('Troca criada com sucesso!'); + this.closeCreate(); + this.loadForGroups(); + }, + error: async () => { + this.createSaving = false; + await this.showToast('Erro ao criar Troca.'); + } + }); + } + + // Helpers + private toNumberOrNull(v: any): number | null { + const n = parseInt(String(v ?? '').trim(), 10); + return Number.isFinite(n) ? n : null; + } + + 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: TrocaKey, v: any): string { + if (v === null || v === undefined || String(v).trim() === '') return '-'; + + if (key === 'dataTroca') { + 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); + } + } +} diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html new file mode 100644 index 0000000..193ead7 --- /dev/null +++ b/src/app/pages/vigencia/vigencia.html @@ -0,0 +1,219 @@ +
+
+
+ LineGestão + +
+
{{ toastMessage }}
+
+
+ +
+ + + + +
+
+ +
+
+
VIGÊNCIA
+
+
GESTÃO DE VIGÊNCIA
+ Controle de contratos e fidelização +
+
+
+ +
+
+ Total Clientes + {{ kpiTotalClientes }} +
+
+ Total Linhas + {{ kpiTotalLinhas }} +
+
+ Total Vencidos + {{ kpiTotalVencidos }} +
+
+ Valor Total + {{ kpiValorTotal | currency:'BRL' }} +
+
+ +
+
+
+ + + +
+
+ +
+ Itens por pág: + +
+
+
+ +
+
+ +
+
+
+ +
+ Nenhum dado encontrado. +
+ +
+ +
+
+
{{ g.cliente }}
+
+ {{ g.linhas }} Registros + {{ g.vencidos }} Vencidos + {{ g.linhas - g.vencidos }} Ativos +
+
+
+
+ +
+ +
+ Linhas do Cliente + Total: {{ g.total | currency:'BRL' }} +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ITEMLINHACONTAUSUÁRIOPLANOEFETIVAÇÃOVENCIMENTOTOTALAÇÕES
{{ row.item }}{{ row.linha }}{{ row.conta || '-' }}{{ row.usuario || '-' }}{{ row.planoContrato || '-' }} + {{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }} + + {{ row.dtTerminoFidelizacao ? (row.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }} + + {{ (row.total || 0) | currency:'BRL' }} + +
+ +
+
Nenhuma linha encontrada.
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+ + + +
+
\ No newline at end of file diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss new file mode 100644 index 0000000..76d67aa --- /dev/null +++ b/src/app/pages/vigencia/vigencia.scss @@ -0,0 +1,178 @@ +/* ========================================================== */ +/* 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; + --danger-bg: rgba(220, 53, 69, 0.1); + --danger-text: #dc3545; + + --radius-xl: 22px; + --radius-lg: 16px; + --shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10); + --glass-bg: rgba(255, 255, 255, 0.86); + --glass-border: 1px solid rgba(227, 61, 207, 0.16); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); +} + +.vigencia-page { + min-height: 100vh; + padding: 0 12px; + display: flex; + justify-content: center; + position: relative; + overflow-y: auto; + + /* Blobs de fundo (Estilo Mureg) */ + 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%); + + .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; } +} + +@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: 100%; max-width: 1280px; position: relative; z-index: 1; margin-top: 40px; margin-bottom: 100px; +} + +.geral-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); + display: flex; flex-direction: column; min-height: 80vh; +} + +/* HEADER */ +.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)); +} + +.header-row-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; } + +.title-badge { + 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); font-size: 13px; font-weight: 800; + i { color: var(--brand); } +} + +.header-title { text-align: center; } +.title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; } +.subtitle { color: var(--muted); font-weight: 700; } + +/* KPIs */ +.mureg-kpis { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + .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; + transition: transform 0.2s; + &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } + .lbl { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; color: var(--muted); } + .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } + .text-brand { color: var(--brand) !important; } + } +} + +/* Controls */ +.search-group { + border-radius: 12px; background: #fff; border: 1px solid rgba(17,18,20,0.15); display: flex; align-items: center; + &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); } + .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; &:focus { outline: none; } } +} + +.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; +} + +/* BODY E GRUPOS */ +.geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; } +.groups-container { padding: 16px; overflow-y: auto; height: 100%; } + +.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-title { font-weight: 800; color: var(--text); } +.group-badges { display: flex; gap: 8px; } +.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); } + &.danger { background: var(--danger-bg); color: var(--danger-text); } + &.ok { background: var(--success-bg); color: var(--success-text); } +} + +.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s; } +@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } + +.chip-muted { 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); } + +/* TABELA MUREG STYLE */ +.inner-table-wrap { max-height: 500px; overflow-y: auto; } +.table-modern { + width: 100%; 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; + } + tbody tr { transition: background 0.2s; &:hover { background-color: rgba(227, 61, 207, 0.05); } } + td { padding: 12px; font-size: 0.875rem; border-bottom: 1px solid rgba(17,18,20,0.04); vertical-align: middle; } +} + +.fw-black { font-weight: 950; } +.text-brand { color: var(--brand) !important; } +.text-blue { color: var(--blue) !important; } +.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.btn-icon { + width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; color: rgba(17,18,20,0.5); + display: flex; align-items: center; justify-content: center; transition: all 0.2s; + &:hover { background: rgba(3,15,170,0.1); color: var(--blue); } +} + +/* FOOTER */ +.geral-footer { + padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; +} +.pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: #fff; margin: 0 2px; } +.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: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } +.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-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); width: 600px; overflow: hidden; animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } +@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } \ No newline at end of file diff --git a/src/app/pages/vigencia/vigencia.spec.ts b/src/app/pages/vigencia/vigencia.spec.ts new file mode 100644 index 0000000..87623c0 --- /dev/null +++ b/src/app/pages/vigencia/vigencia.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; // Necessário para simular HTTP +import { of } from 'rxjs'; // Necessário para simular respostas Observables + +import { VigenciaComponent } from './vigencia'; // O nome correto da classe é VigenciaComponent +import { VigenciaService } from '../../services/vigencia.service'; // Ajuste o caminho se necessário + +describe('VigenciaComponent', () => { + let component: VigenciaComponent; + let fixture: ComponentFixture; + let vigenciaServiceMock: any; + + beforeEach(async () => { + // 1. Criamos um "Dublê" (Mock) do serviço para não chamar o backend real no teste + vigenciaServiceMock = { + getClients: jasmine.createSpy('getClients').and.returnValue(of([])), + getVigencia: jasmine.createSpy('getVigencia').and.returnValue(of({ items: [], total: 0 })), + getGroups: jasmine.createSpy('getGroups').and.returnValue(of({ items: [], total: 0 })) + }; + + await TestBed.configureTestingModule({ + // 2. Importamos o componente (pois é standalone) e o módulo de teste HTTP + imports: [ + VigenciaComponent, + HttpClientTestingModule + ], + // 3. Injetamos o mock no lugar do serviço real + providers: [ + { provide: VigenciaService, useValue: vigenciaServiceMock } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(VigenciaComponent); + component = fixture.componentInstance; + + // O detectChanges dispara o ngOnInit. Como mockamos o serviço, ele não vai dar erro. + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts new file mode 100644 index 0000000..e02f24c --- /dev/null +++ b/src/app/pages/vigencia/vigencia.ts @@ -0,0 +1,217 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service'; + +type SortDir = 'asc' | 'desc'; +type ToastType = 'success' | 'danger'; +type ViewMode = 'lines' | 'groups'; + +@Component({ + selector: 'app-vigencia', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './vigencia.html', + styleUrls: ['./vigencia.scss'], +}) +export class VigenciaComponent implements OnInit { + loading = false; + errorMsg = ''; + + // Filtros + search = ''; + client = ''; + clients: string[] = []; + + // Paginação + page = 1; + pageSize = 10; + total = 0; + + // Ordenação + sortBy = 'cliente'; + sortDir: SortDir = 'asc'; + + // PADRÃO: GROUPS + viewMode: ViewMode = 'groups'; + + // Dados + groups: VigenciaClientGroup[] = []; + rows: VigenciaRow[] = []; + + // === KPIs GERAIS (Vindos do Backend) === + kpiTotalClientes = 0; + kpiTotalLinhas = 0; + kpiTotalVencidos = 0; + kpiValorTotal = 0; + + // === ACORDEÃO === + expandedGroup: string | null = null; + expandedLoading = false; + groupRows: VigenciaRow[] = []; + + // UI + detailsOpen = false; + selectedRow: VigenciaRow | null = null; + toastOpen = false; + toastMessage = ''; + toastType: ToastType = 'success'; + private toastTimer: any = null; + + constructor(private vigenciaService: VigenciaService) {} + + ngOnInit(): void { + this.loadClients(); + this.fetch(1); + } + + setView(mode: ViewMode): void { + if (this.viewMode === mode) return; + this.viewMode = mode; + this.page = 1; + this.expandedGroup = null; + this.groupRows = []; + this.sortBy = mode === 'groups' ? 'cliente' : 'item'; + this.fetch(1); + } + + loadClients(): void { + this.vigenciaService.getClients().subscribe({ + next: (list) => (this.clients = list ?? []), + error: () => (this.clients = []), + }); + } + + get totalPages(): number { + return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + } + + fetch(goToPage?: number): void { + if (goToPage) this.page = goToPage; + this.loading = true; + this.errorMsg = ''; + + if(goToPage && goToPage !== this.page) this.expandedGroup = null; + + if (this.viewMode === 'groups') { + this.fetchGroups(); + } else { + this.fetchLines(); + } + } + + private fetchGroups() { + this.vigenciaService.getGroups({ + search: this.search?.trim(), + page: this.page, + pageSize: this.pageSize, + sortBy: this.sortBy, + sortDir: this.sortDir, + }).subscribe({ + next: (res) => { + // ✅ Preenche Lista + this.groups = res.data.items || []; + this.total = res.data.total || 0; + + // ✅ Preenche KPIs Globais + this.kpiTotalClientes = res.kpis.totalClientes; + this.kpiTotalLinhas = res.kpis.totalLinhas; + this.kpiTotalVencidos = res.kpis.totalVencidos; + this.kpiValorTotal = res.kpis.valorTotal; + + this.loading = false; + }, + error: (err) => this.handleError(err, 'Erro ao carregar clientes.'), + }); + } + + private fetchLines() { + this.vigenciaService.getVigencia({ + search: this.search?.trim(), + client: this.client?.trim(), + page: this.page, + pageSize: this.pageSize, + sortBy: this.sortBy, + sortDir: this.sortDir, + }).subscribe({ + next: (res) => { + this.rows = res.items || []; + this.total = res.total || 0; + this.loading = false; + }, + error: (err) => this.handleError(err, 'Erro ao carregar linhas.'), + }); + } + + toggleGroup(g: VigenciaClientGroup): void { + if (this.expandedGroup === g.cliente) { + this.expandedGroup = null; + this.groupRows = []; + return; + } + + this.expandedGroup = g.cliente; + this.expandedLoading = true; + this.groupRows = []; + + this.vigenciaService.getVigencia({ + client: g.cliente, + page: 1, + pageSize: 200, + sortBy: 'item', + sortDir: 'asc' + }).subscribe({ + next: (res) => { + this.groupRows = res.items || []; + this.expandedLoading = false; + }, + error: () => { + this.showToast('Erro ao carregar detalhes do cliente.', 'danger'); + this.expandedLoading = false; + } + }); + } + + public isVencido(dateValue: any): boolean { + if(!dateValue) return false; + const d = this.parseAnyDate(dateValue); + if(!d) return false; + return this.startOfDay(d) < this.startOfDay(new Date()); + } + + public isAtivo(dateValue: any): boolean { + if(!dateValue) return true; + const d = this.parseAnyDate(dateValue); + if(!d) return true; + return this.startOfDay(d) >= this.startOfDay(new Date()); + } + + public parseAnyDate(value: any): Date | null { + if (!value) return null; + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + public startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + clearFilters() { this.search = ''; this.fetch(1); } + openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; } + closeDetails() { this.detailsOpen = false; } + + handleError(err: HttpErrorResponse, msg: string) { + this.loading = false; + this.expandedLoading = false; + this.errorMsg = (err.error as any)?.message || msg; + this.showToast(msg, 'danger'); + } + + showToast(msg: string, type: ToastType) { + this.toastMessage = msg; this.toastType = type; this.toastOpen = true; + if(this.toastTimer) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => this.toastOpen = false, 3000); + } + hideToast() { this.toastOpen = false; } +} \ No newline at end of file diff --git a/src/app/services/billing.spec.ts b/src/app/services/billing.spec.ts index 69e925a..830b60e 100644 --- a/src/app/services/billing.spec.ts +++ b/src/app/services/billing.spec.ts @@ -1,13 +1,18 @@ import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { Billing } from './billing'; +import { BillingService } from './billing'; -describe('Billing', () => { - let service: Billing; +describe('BillingService', () => { + let service: BillingService; beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(Billing); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [BillingService] + }); + + service = TestBed.inject(BillingService); }); it('should be created', () => { diff --git a/src/app/services/billing.ts b/src/app/services/billing.ts index 4087130..1638710 100644 --- a/src/app/services/billing.ts +++ b/src/app/services/billing.ts @@ -1,91 +1,87 @@ -// src/app/services/billing.ts -import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; export type SortDir = 'asc' | 'desc'; export type TipoCliente = 'PF' | 'PJ'; +export type TipoFiltro = 'ALL' | TipoCliente; export type BillingSortBy = + | 'tipo' | 'item' | 'cliente' | 'qtdlinhas' - | 'lucro' - | 'valorcontratovivo' - | 'valorcontratoline' | 'franquiavivo' - | 'franquialine'; + | 'valorcontratovivo' + | 'franquialine' + | 'valorcontratoline' + | 'lucro' + | 'aparelho' + | 'formapagamento'; -export interface PagedResult { +export interface BillingItem { + id: string; + tipo: string; + item: number; + cliente: string; + + 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: TipoFiltro; + page: number; + pageSize: number; + sortBy: BillingSortBy; + sortDir: SortDir; + search?: string; + client?: string; +} + +export interface ApiPagedResult { 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'; + private readonly baseUrl = 'https://localhost:7205/api/billing'; constructor(private http: HttpClient) {} - getPaged(q: BillingQuery): Observable> { - const sortBy: BillingSortBy = (q.sortBy ?? 'cliente'); - const sortDir: SortDir = (q.sortDir ?? 'asc'); - + getPaged(q: BillingQuery): Observable> { let params = new HttpParams() - .set('tipo', q.tipo) .set('page', String(q.page)) .set('pageSize', String(q.pageSize)) - .set('sortBy', sortBy) - .set('sortDir', sortDir); + .set('sortBy', q.sortBy) + .set('sortDir', q.sortDir); - const search = (q.search ?? '').trim(); - if (search) params = params.set('search', search); + if (q.tipo && q.tipo !== 'ALL') params = params.set('tipo', q.tipo); + if (q.search) params = params.set('search', q.search); + if (q.client) params = params.set('client', q.client); - const client = (q.client ?? '').trim(); - if (client) params = params.set('client', client); - - return this.http.get>(this.baseUrl, { params }); + return this.http.get>(this.baseUrl, { params }); } - getClients(tipo: TipoCliente): Observable { - const params = new HttpParams().set('tipo', tipo); - return this.http.get(`${this.baseUrl}/clients`, { params }); + getAll(): Observable { + const q: BillingQuery = { + tipo: 'ALL', + page: 1, + pageSize: 99999, + sortBy: 'cliente', + sortDir: 'asc' + }; + + return this.getPaged(q).pipe(map((res) => res.items ?? [])); } } diff --git a/src/app/services/dados-usuarios.service.ts b/src/app/services/dados-usuarios.service.ts new file mode 100644 index 0000000..88e69d6 --- /dev/null +++ b/src/app/services/dados-usuarios.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export type SortDir = 'asc' | 'desc'; + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface UserDataRow { + id: string; + item: number; + linha: string | null; + cliente: string | null; + cpf: string | null; + email: string | null; + celular: string | null; + rg: string | null; + endereco: string | null; + telefoneFixo: string | null; + dataNascimento: string | null; +} + +export interface UserDataClientGroup { + cliente: string; + totalRegistros: number; + comCpf: number; + comEmail: number; +} + +export interface UserDataKpis { + totalRegistros: number; + clientesUnicos: number; + comCpf: number; + comEmail: number; +} + +export interface UserDataGroupResponse { + data: PagedResult; + kpis: UserDataKpis; +} + +@Injectable({ providedIn: 'root' }) +export class DadosUsuariosService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + getGroups(opts: { + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortDir?: SortDir; + }): Observable { + let params = new HttpParams(); + if (opts.search) params = params.set('search', opts.search); + + params = params.set('page', String(opts.page || 1)); + params = params.set('pageSize', String(opts.pageSize || 10)); + params = params.set('sortBy', opts.sortBy || 'cliente'); + params = params.set('sortDir', opts.sortDir || 'asc'); + + return this.http.get(`${this.baseApi}/user-data/groups`, { params }); + } + + getRows(opts: { + search?: string; + client?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortDir?: SortDir; + }): Observable> { + let params = new HttpParams(); + if (opts.search) params = params.set('search', opts.search); + if (opts.client) params = params.set('client', opts.client); + + params = params.set('page', String(opts.page || 1)); + params = params.set('pageSize', String(opts.pageSize || 20)); + params = params.set('sortBy', opts.sortBy || 'item'); + params = params.set('sortDir', opts.sortDir || 'asc'); + + return this.http.get>(`${this.baseApi}/user-data`, { params }); + } + + getClients(): Observable { + return this.http.get(`${this.baseApi}/user-data/clients`); + } + + getById(id: string): Observable { + return this.http.get(`${this.baseApi}/user-data/${id}`); + } +} \ No newline at end of file diff --git a/src/app/services/parcelamento.service.ts b/src/app/services/parcelamento.service.ts new file mode 100644 index 0000000..fcfb8cf --- /dev/null +++ b/src/app/services/parcelamento.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface ParcelamentoKpis { + linhas: number; + clientes: number; + totalValorCheio: number; + totalDesconto: number; + totalComDesconto: number; + meses: number; +} + +export interface ParcelamentoMonthlyPoint { + month: string; // ex: "2026-01" + label: string; // ex: "JAN/2026" + total: number; +} + +export interface ParcelamentoTopLine { + linha: string | null; + cliente: string | null; + total: number; +} + +@Injectable({ providedIn: 'root' }) +export class ParcelamentoService { + private readonly baseApi: string; + + constructor(private http: HttpClient) { + // ✅ igual ao seu VigenciaService + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + } + + // ✅ /api/parcelamento/clients + getClients(): Observable { + return this.http.get(`${this.baseApi}/parcelamento/clients`); + } + + // ✅ /api/parcelamento/kpis?cliente=...&linha=... + getKpis(opts?: { cliente?: string; linha?: string }): Observable { + let params = new HttpParams(); + if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim()); + if (opts?.linha && opts.linha.trim()) params = params.set('linha', opts.linha.trim()); + return this.http.get(`${this.baseApi}/parcelamento/kpis`, { params }); + } + + // ✅ /api/parcelamento/series/monthly?cliente=...&linha=... + getMonthlySeries(opts?: { cliente?: string; linha?: string }): Observable { + let params = new HttpParams(); + if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim()); + if (opts?.linha && opts.linha.trim()) params = params.set('linha', opts.linha.trim()); + return this.http.get(`${this.baseApi}/parcelamento/series/monthly`, { params }); + } + + // ✅ /api/parcelamento/top-lines?cliente=...&take=10 + getTopLines(opts?: { cliente?: string; take?: number }): Observable { + let params = new HttpParams(); + params = params.set('take', String(opts?.take ?? 10)); + if (opts?.cliente && opts.cliente.trim()) params = params.set('cliente', opts.cliente.trim()); + return this.http.get(`${this.baseApi}/parcelamento/top-lines`, { params }); + } +} diff --git a/src/app/services/vigencia.service.ts b/src/app/services/vigencia.service.ts new file mode 100644 index 0000000..b062c74 --- /dev/null +++ b/src/app/services/vigencia.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export type SortDir = 'asc' | 'desc'; + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface VigenciaRow { + id: string; + item: number; + conta: string | null; + linha: string | null; + cliente: string | null; + usuario: string | null; + planoContrato: string | null; + dtEfetivacaoServico: string | null; + dtTerminoFidelizacao: string | null; + total: number | null; +} + +export interface VigenciaClientGroup { + cliente: string; + linhas: number; + total: number; + vencidos: number; + aVencer30: number; + proximoVencimento: string | null; + ultimoVencimento: string | null; +} + +// ✅ NOVAS INTERFACES DE RESPOSTA +export interface VigenciaKpis { + totalClientes: number; + totalLinhas: number; + totalVencidos: number; + valorTotal: number; +} + +export interface VigenciaGroupResponse { + data: PagedResult; + kpis: VigenciaKpis; +} + +@Injectable({ providedIn: 'root' }) +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`; + } + + getVigencia(opts: { search?: string; client?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable> { + let params = new HttpParams(); + if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim()); + if (opts.client && opts.client.trim()) params = params.set('client', opts.client.trim()); + + params = params.set('page', String(opts.page ?? 1)); + params = params.set('pageSize', String(opts.pageSize ?? 20)); + params = params.set('sortBy', (opts.sortBy ?? 'item').trim()); + params = params.set('sortDir', opts.sortDir ?? 'asc'); + + return this.http.get>(`${this.baseApi}/lines/vigencia`, { params }); + } + + // ✅ Retorna o objeto composto (Dados + KPIs) + getGroups(opts: { search?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable { + let params = new HttpParams(); + if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim()); + + params = params.set('page', String(opts.page ?? 1)); + params = params.set('pageSize', String(opts.pageSize ?? 20)); + params = params.set('sortBy', (opts.sortBy ?? 'cliente').trim()); + params = params.set('sortDir', opts.sortDir ?? 'asc'); + + return this.http.get(`${this.baseApi}/lines/vigencia/groups`, { params }); + } + + getClients(): Observable { + return this.http.get(`${this.baseApi}/lines/vigencia/clients`); + } +} \ No newline at end of file