From 096306e85266973bac6b34d049c65f9595451295 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 27 Feb 2026 14:28:50 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20Adi=C3=A7=C3=A3o=20Lote=20de=20Linh?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 + package-lock.json | 12 +- package.json | 1 + src/app/components/header/header.ts | 11 +- src/app/pages/faturamento/faturamento.ts | 49 +- src/app/pages/geral/geral.html | 553 ++++++++++++------- src/app/pages/geral/geral.scss | 176 ++++++ src/app/pages/geral/geral.ts | 552 +++++++++++++++++- src/app/pages/historico/historico.html | 11 +- src/app/pages/historico/historico.scss | 50 +- src/app/pages/historico/historico.ts | 6 +- src/app/pages/notificacoes/notificacoes.html | 38 +- src/app/pages/notificacoes/notificacoes.scss | 41 +- src/app/pages/notificacoes/notificacoes.ts | 111 +++- src/app/pages/vigencia/vigencia.html | 20 +- src/app/pages/vigencia/vigencia.scss | 55 +- src/app/pages/vigencia/vigencia.ts | 99 +++- src/app/services/historico.service.ts | 4 +- src/app/services/notifications.service.ts | 2 +- src/app/services/vigencia.service.ts | 11 + 20 files changed, 1536 insertions(+), 281 deletions(-) diff --git a/README.md b/README.md index 5ba89c0..1bfa1ad 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,18 @@ Angular CLI does not come with an end-to-end testing framework by default. You c ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. + +## Planilha Modelo (GERAL) - Lote de Linhas + +- Local do botão: + - Página `Geral` + - Modal `Adicionar linha` ou `Novo cliente` + - Modo `Lote de Linhas` + - Bloco de importação por Excel + - Botão: `Baixar Modelo (GERAL)` + +- Endpoint chamado pelo front-end: + - `GET /api/templates/planilha-geral` + +- Arquivo baixado: + - `MODELO_GERAL_LINEGESTAO.xlsx` diff --git a/package-lock.json b/package-lock.json index bb6e153..bcb1605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/bootstrap": "^5.2.10", "@types/jasmine": "~5.1.0", "@types/node": "^20.17.19", + "baseline-browser-mapping": "^2.10.0", "jasmine-core": "~5.9.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -3732,13 +3733,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/beasties": { diff --git a/package.json b/package.json index f3f70f1..46bb015 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/bootstrap": "^5.2.10", "@types/jasmine": "~5.1.0", "@types/node": "^20.17.19", + "baseline-browser-mapping": "^2.10.0", "jasmine-core": "~5.9.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 380c435..b34a86f 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -343,7 +343,10 @@ export class Header implements AfterViewInit, OnDestroy { } getVigenciaLabel(notification: NotificationDto): string { - return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em'; + const tipo = this.getNotificationTipo(notification); + if (tipo === 'Vencido') return 'Venceu em'; + if (tipo === 'AVencer') return 'Vence em'; + return 'Atualizado em'; } getVigenciaDate(notification: NotificationDto): string { @@ -357,7 +360,11 @@ export class Header implements AfterViewInit, OnDestroy { return parsed.toLocaleDateString('pt-BR'); } - getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + getNotificationTipo(notification: NotificationDto): string { + if (notification.tipo === 'RenovacaoAutomatica') { + return 'RenovacaoAutomatica'; + } + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; const parsed = this.parseDateOnly(reference); if (!parsed) return notification.tipo; diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index 889a440..c0d873e 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -24,6 +24,7 @@ import { BillingUpdateRequest } from '../../services/billing'; import { AuthService } from '../../services/auth.service'; +import { LinesService } from '../../services/lines.service'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; interface BillingClientGroup { @@ -51,6 +52,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { constructor( @Inject(PLATFORM_ID) private platformId: object, private billing: BillingService, + private linesService: LinesService, private cdr: ChangeDetectorRef, private authService: AuthService ) {} @@ -106,6 +108,8 @@ export class Faturamento implements AfterViewInit, OnDestroy { isAdmin = false; private searchTimer: any = null; + private searchResolvedClients: string[] = []; + private searchResolveVersion = 0; // cache do ALL private allCache: BillingItem[] = []; @@ -351,22 +355,59 @@ export class Faturamento implements AfterViewInit, OnDestroy { onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); - this.searchTimer = setTimeout(() => { + this.searchTimer = setTimeout(async () => { this.page = 1; this.expandedGroup = null; this.groupRows = []; + await this.resolveSearchClientsByLineOrChip(); this.refreshData(); }, 250); } clearSearch() { this.searchTerm = ''; + this.searchResolvedClients = []; this.page = 1; this.expandedGroup = null; this.groupRows = []; this.refreshData(); } + private isSpecificLineOrChipSearch(term: string): boolean { + const digits = (term ?? '').replace(/\D/g, ''); + return digits.length >= 8; + } + + private async resolveSearchClientsByLineOrChip(): Promise { + const term = (this.searchTerm ?? '').trim(); + const requestVersion = ++this.searchResolveVersion; + + if (!term || !this.isSpecificLineOrChipSearch(term)) { + this.searchResolvedClients = []; + return; + } + + try { + const response = await new Promise((resolve, reject) => { + this.linesService.getLines(1, 200, term).subscribe({ + next: resolve, + error: reject + }); + }); + + if (requestVersion !== this.searchResolveVersion) return; + + const clients = (response?.items ?? []) + .map((x: any) => (x?.cliente ?? '').toString().trim()) + .filter((x: string) => !!x); + + this.searchResolvedClients = Array.from(new Set(clients)); + } catch { + if (requestVersion !== this.searchResolveVersion) return; + this.searchResolvedClients = []; + } + } + // -------------------------- // Data // -------------------------- @@ -513,8 +554,12 @@ export class Faturamento implements AfterViewInit, OnDestroy { } const term = this.normalizeText(this.searchTerm); + const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x))); if (term) { - arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term)); + arr = arr.filter((r) => + this.buildGlobalSearchBlob(r).includes(term) || + (resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente))) + ); } // KPIs diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index baf4fc8..b5c60c1 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -42,6 +42,7 @@ + +
+ + + + + + + + + + +
@@ -316,25 +345,48 @@ + - + + - + - + @@ -451,7 +503,7 @@ - - diff --git a/src/app/pages/historico/historico.scss b/src/app/pages/historico/historico.scss index 3cc55ae..62d927f 100644 --- a/src/app/pages/historico/historico.scss +++ b/src/app/pages/historico/historico.scss @@ -214,6 +214,7 @@ .filter-field { display: grid; gap: 6px; + min-width: 0; label { font-size: 11px; @@ -224,6 +225,9 @@ } input { + width: 100%; + max-width: 100%; + min-width: 0; height: 40px; border-radius: 10px; border: 1px solid rgba(15, 23, 42, 0.15); @@ -244,11 +248,25 @@ grid-column: span 2; } +.filter-user { + min-width: 0; + width: 100%; + + input { + width: 100%; + max-width: 100%; + } +} + .search-group { - max-width: 270px; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 40px; border-radius: 12px; overflow: hidden; display: flex; + flex-wrap: nowrap; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); @@ -262,6 +280,7 @@ } .input-group-text { + flex: 0 0 auto; background: transparent; border: none; color: rgba(17, 18, 20, 0.5); @@ -272,10 +291,13 @@ } .form-control { + flex: 1 1 auto; + width: 100%; + min-width: 0; border: none; background: transparent; - height: auto; - padding: 10px 0; + height: 40px; + padding: 0 8px; font-size: 0.9rem; color: var(--text); box-shadow: none; @@ -285,6 +307,7 @@ } .btn-clear { + flex: 0 0 auto; border: none; background: transparent; color: rgba(17, 18, 20, 0.45); @@ -399,7 +422,8 @@ .table-modern th:nth-child(5), .table-modern td:nth-child(5) { - text-align: left; + text-align: center; + min-width: 240px; } .table-modern th:nth-child(2), @@ -447,13 +471,15 @@ .entity-cell { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; + justify-content: center; + gap: 8px; } .entity-label { font-weight: 700; color: var(--text); + text-align: center; + max-width: 300px; } .entity-id { @@ -677,10 +703,10 @@ .entity-cell { flex-direction: row; align-items: center; - justify-content: flex-start; + justify-content: center; gap: 6px; } - .entity-label { flex: 1 1 auto; min-width: 0; } + .entity-label { flex: 1 1 auto; min-width: 0; text-align: center; } .expand-btn { align-self: center; flex-shrink: 0; } } @@ -870,18 +896,14 @@ } .entity-cell { - justify-content: flex-start; + justify-content: center; gap: 6px; } .entity-label { min-width: 0; flex: 1 1 auto; - } - - .entity-id { - margin-top: 2px; - line-height: 1.2; + text-align: center; } .details-row td { diff --git a/src/app/pages/historico/historico.ts b/src/app/pages/historico/historico.ts index e929105..5ce35cf 100644 --- a/src/app/pages/historico/historico.ts +++ b/src/app/pages/historico/historico.ts @@ -36,7 +36,7 @@ export class Historico implements OnInit { filterPageName = ''; filterAction = ''; - filterUserId = ''; + filterUser = ''; filterSearch = ''; dateFrom = ''; dateTo = ''; @@ -84,7 +84,7 @@ export class Historico implements OnInit { clearFilters(): void { this.filterPageName = ''; this.filterAction = ''; - this.filterUserId = ''; + this.filterUser = ''; this.filterSearch = ''; this.dateFrom = ''; this.dateTo = ''; @@ -221,7 +221,7 @@ export class Historico implements OnInit { pageSize: this.pageSize, pageName: this.filterPageName || undefined, action: this.filterAction || undefined, - userId: this.filterUserId?.trim() || undefined, + user: this.filterUser?.trim() || undefined, search: this.filterSearch?.trim() || undefined, dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, dateTo: this.toIsoDate(this.dateTo, true) || undefined, diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index 1376e22..a9517bc 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -30,7 +30,7 @@ @@ -115,8 +115,8 @@ class="list-item" *ngFor="let n of filteredNotifications" [class.is-read]="n.lida" - [class.is-danger]="getNotificationTipo(n) === 'Vencido'" - [class.is-warning]="getNotificationTipo(n) === 'AVencer'" + [class.is-danger]="isVencido(n)" + [class.is-warning]="isAVencer(n)" >
@@ -126,7 +126,12 @@
- + +
@@ -156,8 +161,8 @@ {{ n.planoContrato || '-' }}
- - {{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }} + + {{ getStatusLabel(n) }}
@@ -173,6 +178,27 @@ {{ n.lida ? 'Restaurar' : 'Marcar lida' }} + + diff --git a/src/app/pages/notificacoes/notificacoes.scss b/src/app/pages/notificacoes/notificacoes.scss index ae15b80..435bc37 100644 --- a/src/app/pages/notificacoes/notificacoes.scss +++ b/src/app/pages/notificacoes/notificacoes.scss @@ -260,6 +260,7 @@ $border: #e5e7eb; .bi-x-circle-fill { color: $danger; } .bi-clock-fill { color: $warning; } + .bi-check2-circle { color: $primary; } } .item-content { flex: 1; min-width: 0; } @@ -290,9 +291,9 @@ $border: #e5e7eb; display: flex; flex-direction: column; gap: 6px; - align-items: flex-end; + align-items: flex-start; min-width: 170px; - text-align: right; + text-align: left; } .date-pill { @@ -323,17 +324,22 @@ $border: #e5e7eb; &.danger { background: rgba($danger, 0.1); color: $danger; } &.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); } + &.info { background: rgba($primary, 0.12); color: $primary; } } .item-actions { - margin-left: 12px; align-self: center; + margin-left: 12px; + align-self: center; + display: flex; + flex-direction: column; + gap: 6px; } .btn-action { background: white; border: 1px solid $border; - padding: 8px 12px; border-radius: 8px; + padding: 6px 10px; border-radius: 7px; cursor: pointer; - color: $text-main; font-size: 13px; font-weight: 600; + color: $text-main; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 6px; transition: all 0.2s; @@ -344,6 +350,18 @@ $border: #e5e7eb; @media(min-width: 768px) { opacity: 0.6; } } +.btn-action.ghost { + background: rgba($primary, 0.06); + border-color: rgba($primary, 0.25); + color: $primary; +} + +.btn-action.renew { + background: rgba($warning, 0.12); + border-color: rgba($warning, 0.35); + color: color.adjust($warning, $lightness: -22%); +} + /* ========================================================================== RESPONSIVIDADE MOBILE (Central de Notificações) ========================================================================== */ @@ -634,14 +652,17 @@ $border: #e5e7eb; grid-column: 1 / -1; margin: 2px 0 0 0; width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 8px; } .btn-action { width: 100%; justify-content: center; - padding: 8px 10px; - border-radius: 10px; - font-size: 12px; + padding: 6px 8px; + border-radius: 9px; + font-size: 11px; gap: 6px; } @@ -727,7 +748,7 @@ $border: #e5e7eb; } .btn-action { - font-size: 11px; - padding: 7px 8px; + font-size: 10px; + padding: 6px 7px; } } diff --git a/src/app/pages/notificacoes/notificacoes.ts b/src/app/pages/notificacoes/notificacoes.ts index bb509da..064e65e 100644 --- a/src/app/pages/notificacoes/notificacoes.ts +++ b/src/app/pages/notificacoes/notificacoes.ts @@ -1,9 +1,11 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { NotificationsService, NotificationDto } from '../../services/notifications.service'; +import { VigenciaService } from '../../services/vigencia.service'; @Component({ selector: 'app-notificacoes', @@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy { bulkUnreadLoading = false; exportLoading = false; selectedIds = new Set(); + renewingKey: string | null = null; private readonly subs = new Subscription(); - constructor(private notificationsService: NotificationsService) {} + constructor( + private notificationsService: NotificationsService, + private router: Router, + private vigenciaService: VigenciaService + ) {} ngOnInit(): void { this.loadNotifications(); @@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy { return parsed.toLocaleDateString('pt-BR'); } - getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' { + getNotificationTipo(notification: NotificationDto): string { + if (notification.tipo === 'RenovacaoAutomatica') { + return 'RenovacaoAutomatica'; + } + const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData; const parsed = this.parseDateOnly(reference); if (!parsed) return notification.tipo; @@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy { return parsed < today ? 'Vencido' : 'AVencer'; } + isVencido(notification: NotificationDto): boolean { + return this.getNotificationTipo(notification) === 'Vencido'; + } + + isAVencer(notification: NotificationDto): boolean { + return this.getNotificationTipo(notification) === 'AVencer'; + } + + isAutoRenew(notification: NotificationDto): boolean { + return this.getNotificationTipo(notification) === 'RenovacaoAutomatica'; + } + + getStatusLabel(notification: NotificationDto): string { + if (this.isAutoRenew(notification)) return 'Renovação automática'; + return this.isVencido(notification) ? 'Vencido' : 'A vencer'; + } + + goToVigencia(notification: NotificationDto): void { + const id = (notification.vigenciaLineId ?? '').trim(); + const linha = (notification.linha ?? '').trim(); + if (!id && !linha) return; + + this.router.navigate(['/vigencia'], { + queryParams: { lineId: id || null, linha: linha || null, open: 'edit' } + }); + } + + renewFromNotification(notification: NotificationDto): void { + if (!this.isAVencer(notification)) return; + const years = 2; + const lockKey = notification.id; + if (this.renewingKey === lockKey) return; + this.renewingKey = lockKey; + + const vigenciaLineId = (notification.vigenciaLineId ?? '').trim(); + if (vigenciaLineId) { + this.scheduleByVigenciaId(vigenciaLineId); + return; + } + + const linha = (notification.linha ?? '').trim(); + if (!linha) { + this.renewingKey = null; + return; + } + + const onlyDigits = linha.replace(/\D/g, ''); + const lookup = onlyDigits || linha; + this.vigenciaService.getVigencia({ + search: lookup, + page: 1, + pageSize: 20, + sortBy: 'item', + sortDir: 'asc' + }).subscribe({ + next: (res) => { + const rows = res?.items ?? []; + const found = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0]; + const id = (found?.id ?? '').trim(); + if (!id) { + this.renewingKey = null; + return; + } + this.scheduleByVigenciaId(id); + }, + error: () => { + this.renewingKey = null; + } + }); + } + + isRenewing(notification: NotificationDto): boolean { + return this.renewingKey === notification.id; + } + + private scheduleByVigenciaId(id: string): void { + const years = 2; + this.vigenciaService.configureAutoRenew(id, { years }).subscribe({ + next: () => { + this.renewingKey = null; + this.loadNotifications(); + }, + error: () => { + this.renewingKey = null; + } + }); + } + private loadNotifications() { this.loading = true; this.error = false; @@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy { private shouldMarkRead(n: NotificationDto): boolean { if (this.filter === 'todas') return true; - if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer'; - if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido'; + if (this.filter === 'aVencer') return this.isAVencer(n); + if (this.filter === 'vencidas') return this.isVencido(n); return false; } @@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy { return this.notifications.filter(n => n.lida); } if (this.filter === 'vencidas') { - return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido'); + return this.notifications.filter(n => !n.lida && this.isVencido(n)); } if (this.filter === 'aVencer') { - return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer'); + return this.notifications.filter(n => !n.lida && this.isAVencer(n)); } // "todas" aqui representa o inbox: pendentes (não lidas). return this.notifications.filter(n => !n.lida); diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 31d5fbf..499e57b 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -119,7 +119,7 @@ - + @@ -132,7 +132,7 @@ - + - diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index ca2a5c5..6ed40b2 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -103,7 +103,7 @@ export class DadosUsuarios implements OnInit { createClientsLoading = false; createLinesLoading = false; - isAdmin = false; + isSysAdmin = false; toastOpen = false; toastMessage = ''; toastType: 'success' | 'danger' = 'success'; @@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit { ) {} ngOnInit(): void { - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); this.fetch(1); } @@ -283,7 +283,7 @@ export class DadosUsuarios implements OnInit { closeDetails() { this.detailsOpen = false; } openEdit(row: UserDataRow) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.service.getById(row.id).subscribe({ next: (fullData: UserDataRow) => { this.editingId = fullData.id; @@ -366,7 +366,7 @@ export class DadosUsuarios implements OnInit { // CREATE // ========================== openCreate() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.resetCreateModel(); this.createOpen = true; this.preloadGeralClients(); @@ -532,7 +532,7 @@ export class DadosUsuarios implements OnInit { } openDelete(row: UserDataRow) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.deleteTarget = row; this.deleteOpen = true; } diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index 340d671..b8dc60b 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -286,8 +286,8 @@
- - + +
diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index d001fb7..0c46cbb 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -105,7 +105,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { deleteOpen = false; deleteTarget: BillingItem | null = null; - isAdmin = false; + isSysAdmin = false; private searchTimer: any = null; private searchResolvedClients: string[] = []; @@ -164,7 +164,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); setTimeout(() => { this.refreshData(true); @@ -714,7 +714,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { } onEditar(r: BillingItem) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.editingId = r.id; this.editModel = { ...r }; this.editOpen = true; @@ -729,7 +729,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { } onDelete(r: BillingItem) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.deleteTarget = r; this.deleteOpen = true; this.cdr.detectChanges(); diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 0c17394..c845975 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -35,7 +35,7 @@ +
+ + + + + + + + + + +
@@ -334,7 +362,7 @@ - + @@ -351,23 +379,29 @@ [attr.aria-label]="'Selecionar linha ' + (r.linha || r.item)" /> + + + + - + + + @@ -479,14 +513,14 @@ {{ statusLabel(r.status) }} - + diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 7ef3ceb..7ec7db1 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -274,7 +274,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return `${apiBase}/templates`; })(); loading = false; - isAdmin = false; + isSysAdmin = false; isGestor = false; isClientRestricted = false; @@ -691,9 +691,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isGestor = this.authService.hasRole('gestor'); - this.isClientRestricted = !(this.isAdmin || this.isGestor); + this.isClientRestricted = !(this.isSysAdmin || this.isGestor); if (this.isClientRestricted) { this.filterSkil = 'ALL'; @@ -1760,7 +1760,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onImportExcel() { - if (!this.isAdmin) { + if (!this.isSysAdmin) { await this.showToast('Você não tem permissão para importar planilha.'); return; } @@ -1771,7 +1771,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } onExcelSelected(ev: Event) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -1961,7 +1961,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onRemover(r: LineRow, fromGroup = false) { - if (!this.isAdmin) { + if (!this.isSysAdmin) { await this.showToast('Apenas sysadmin pode remover linhas.'); return; } diff --git a/src/app/pages/login/login.html b/src/app/pages/login/login.html index e66cde4..d3ae823 100644 --- a/src/app/pages/login/login.html +++ b/src/app/pages/login/login.html @@ -21,7 +21,7 @@ type="email" id="email" formControlName="username" - placeholder="admin@empresa.com" + placeholder="usuario@empresa.com" [class.error]="hasError('username')" >
E-mail obrigatório ou inválido.
diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html index db3af8f..fb8c03c 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html +++ b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html @@ -113,7 +113,7 @@ type="button" title="Excluir" aria-label="Excluir" - *ngIf="isAdmin" + *ngIf="isSysAdmin" (click)="remove.emit(row)"> diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts index fa8d84e..45a2629 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts +++ b/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts @@ -26,7 +26,7 @@ export class ParcelamentosTableComponent { @Input() items: ParcelamentoViewItem[] = []; @Input() loading = false; @Input() errorMessage = ''; - @Input() isAdmin = false; + @Input() isSysAdmin = false; @Input() segment: ParcelamentoSegment = 'todos'; @Input() segmentCounts: Record = { diff --git a/src/app/pages/parcelamentos/parcelamentos.html b/src/app/pages/parcelamentos/parcelamentos.html index 619e398..1697b20 100644 --- a/src/app/pages/parcelamentos/parcelamentos.html +++ b/src/app/pages/parcelamentos/parcelamentos.html @@ -65,7 +65,7 @@ [total]="total" [pageSize]="pageSize" [pageSizeOptions]="pageSizeOptions" - [isAdmin]="isAdmin" + [isSysAdmin]="isSysAdmin" (segmentChange)="setSegment($event)" (detail)="openDetails($event)" (edit)="openEdit($event)" diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts index c879fe2..0b2d3f1 100644 --- a/src/app/pages/parcelamentos/parcelamentos.ts +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -87,7 +87,7 @@ export class Parcelamentos implements OnInit, OnDestroy { kpiCards: ParcelamentoKpi[] = []; activeChips: FilterChip[] = []; - isAdmin = false; + isSysAdmin = false; detailOpen = false; detailLoading = false; @@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy { } private syncPermissions(): void { - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); } get totalPages(): number { @@ -440,7 +440,7 @@ export class Parcelamentos implements OnInit, OnDestroy { } openDelete(item: ParcelamentoViewItem): void { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.deleteTarget = item; this.deleteError = ''; this.deleteOpen = true; diff --git a/src/app/pages/system-provision-user/system-provision-user.html b/src/app/pages/system-provision-user/system-provision-user.html index 983d7d6..6608e61 100644 --- a/src/app/pages/system-provision-user/system-provision-user.html +++ b/src/app/pages/system-provision-user/system-provision-user.html @@ -7,7 +7,7 @@
- SYSTEM ADMIN + SYSADMIN

Fornecer Usuário para Cliente

Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.

diff --git a/src/app/pages/system-provision-user/system-provision-user.ts b/src/app/pages/system-provision-user/system-provision-user.ts index 90ea561..bb60f0b 100644 --- a/src/app/pages/system-provision-user/system-provision-user.ts +++ b/src/app/pages/system-provision-user/system-provision-user.ts @@ -11,10 +11,10 @@ import { } from '@angular/forms'; import { - SystemAdminService, + SysadminService, SystemTenantDto, CreateSystemTenantUserResponse, -} from '../../services/system-admin.service'; +} from '../../services/sysadmin.service'; type RoleOption = { value: string; @@ -50,7 +50,7 @@ export class SystemProvisionUserPage implements OnInit { constructor( private fb: FormBuilder, - private systemAdminService: SystemAdminService + private sysadminService: SysadminService ) { this.provisionForm = this.fb.group( { @@ -75,7 +75,7 @@ export class SystemProvisionUserPage implements OnInit { this.tenantsLoading = true; this.tenantsError = ''; - this.systemAdminService + this.sysadminService .listTenants({ source: this.sourceType, active: true }) .subscribe({ next: (tenants) => { @@ -133,7 +133,7 @@ export class SystemProvisionUserPage implements OnInit { this.submitting = true; this.setFormDisabled(true); - this.systemAdminService + this.sysadminService .createTenantUser(tenantId, { name: nameRaw, email, diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 499e57b..1ae2d25 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -24,7 +24,7 @@ Controle de contratos e fidelização
-
@@ -157,8 +157,8 @@ Renovar +2 anos - - + +
diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index 725b26a..31d1148 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -100,7 +100,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { clientsFromGeral: string[] = []; planOptions: string[] = []; - isAdmin = false; + isSysAdmin = false; toastOpen = false; toastMessage = ''; toastType: ToastType = 'success'; @@ -117,7 +117,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); this.loadClients(); this.loadPlanRules(); this.fetch(1); @@ -314,7 +314,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { closeDetails() { this.detailsOpen = false; } openEdit(r: VigenciaRow) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.editingId = r.id; this.editModel = { ...r }; this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico); @@ -365,7 +365,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { // CREATE // ========================== openCreate() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.resetCreateModel(); this.createOpen = true; this.preloadGeralClients(); @@ -544,7 +544,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { } openDelete(r: VigenciaRow) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.deleteTarget = r; this.deleteOpen = true; } @@ -613,7 +613,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { private openVigenciaLineById(lineId: string, openMode: string): void { this.vigenciaService.getById(lineId).subscribe({ next: (row) => { - if (this.isAdmin && openMode !== 'details') { + if (this.isSysAdmin && openMode !== 'details') { this.openEdit(row); return; } @@ -643,7 +643,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { return; } - if (this.isAdmin && openMode !== 'details') { + if (this.isSysAdmin && openMode !== 'details') { this.openEdit(match); return; } diff --git a/src/app/services/system-admin.service.ts b/src/app/services/sysadmin.service.ts similarity index 97% rename from src/app/services/system-admin.service.ts rename to src/app/services/sysadmin.service.ts index 044183d..a43b9e9 100644 --- a/src/app/services/system-admin.service.ts +++ b/src/app/services/sysadmin.service.ts @@ -29,7 +29,7 @@ export type CreateSystemTenantUserResponse = { }; @Injectable({ providedIn: 'root' }) -export class SystemAdminService { +export class SysadminService { private readonly baseApi: string; constructor(private http: HttpClient) {
+ + ITEM LINHA USUÁRIO STATUS VENCIMENTOAÇÕESAÇÕES
+ + {{ r.item }}{{ r.linha }} + {{ r.linha }} +
ICCID: {{ r.chip }}
+
{{ r.usuario || '-' }} {{ statusLabel(r.status) }} {{ r.contrato }} +
@@ -425,7 +477,7 @@
-
AÇÕESAÇÕES
{{ r.skil }} {{ r.contrato }} +
@@ -500,14 +552,14 @@ -
+
-
Entrada em Massa
+
Importar Planilha (Colunas da GERAL)
- Cole ou digite várias linhas em sequência. Formato padrão: - linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao + Use uma planilha Excel com os mesmos cabeçalhos da GERAL (não precisa ter uma aba chamada GERAL). A coluna ITÉM não é necessária; se vier preenchida, será ignorada e o sistema gera a sequência automaticamente.
-
- - +
+ + +
-
- - Ordem Oficial de Colunas - Use essa ordem para reduzir erro de importação por texto - - -
-
-
- {{ i + 1 }} - - - {{ col.label }} * - - - {{ col.canUseDefault ? 'Aceita parâmetro padrão do lote' : 'Preenchimento por linha' }} - - {{ col.note }} - -
-
-
- Regra: valor por linha sobrescreve parâmetro padrão do lote. Se um campo - obrigatório ficar vazio, a linha entra como inválida. -
-
-
- -
- - Parâmetros Padrão do Lote (opcional) - Usados quando a coluna não vier na entrada em massa - - -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- - - -
- - - - - - -
- -
+
- Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }} - Válidas: {{ batchMassPreview?.validRows || 0 }} - - Inválidas: {{ batchMassPreview?.invalidRows || 0 }} - - - Duplicadas: {{ batchMassPreview?.duplicateRows || 0 }} - - Separador: {{ batchMassSeparatorLabel }} - Com cabeçalho + Aba: {{ excelPreview.sheetName || 'GERAL' }} + Linhas lidas: {{ excelPreview.totalRows || 0 }} + Válidas: {{ excelPreview.validRows || 0 }} + Inválidas: {{ excelPreview.invalidRows || 0 }} + Duplicadas: {{ excelPreview.duplicateRows || 0 }} + Próx. ITÉM (sistema): {{ excelPreview.nextItemStart }}
-
-
Erros de parsing
-
    -
  • {{ err }}
  • +
    + Erros de cabeçalho/estrutura +
      +
    • + {{ err.column }}: {{ err.message }} +
    -
    +
    + Avisos +
      +
    • + {{ warn.column }}: {{ warn.message }} +
    • +
    +
    + +
    + + +
    + +
    - + + + - - + - - - - - - - - + + + + + + + +
    Linha origemPlanilhaITÉM (origem)ITÉM (sistema) Linha ChipPlanoStatus ContaStatus Validação
    #{{ row.line }}{{ row.data['linha'] || '-' }}{{ row.data['chip'] || '-' }}{{ row.data['planoContrato'] || '-' }}{{ row.data['status'] || '-' }}{{ row.data['conta'] || '-' }} - OK -
    -
    {{ row.errors[0] }}
    -
    +{{ row.errors.length - 1 }} pendência(s)
    +
    #{{ row.sourceRowNumber }}{{ row.sourceItem ?? '-' }}{{ row.generatedItemPreview ?? '-' }}{{ row.data.linha || '-' }}{{ row.data.chip || '-' }}{{ row.data.conta || '-' }}{{ row.data.status || '-' }} +
    + OK +
    +
    +
    + {{ getBatchExcelRowPrimaryError(row) }} +
    +
    + +{{ (row.errors.length || 0) - 1 }} pendência(s) +
    -
    - Mostrando 5 de {{ batchMassPreview?.recognizedRows }} linha(s) na prévia. +
    + Mostrando {{ batchExcelPreviewRowsPreview.length }} de {{ excelPreview.rows.length || 0 }} linha(s) na prévia da planilha.
    @@ -1160,8 +1101,8 @@
    - Nenhuma linha no lote ainda. Use a Entrada em Massa acima para colar/digitar as linhas em - sequência e carregá-las na grade. + Nenhuma linha no lote ainda. Use a importação por planilha acima para pré-visualizar e + carregar as linhas na grade.
    @@ -1272,7 +1213,7 @@
    - Após carregar o lote pela Entrada em Massa, selecione uma linha e clique em + Após carregar o lote pela importação da planilha, selecione uma linha e clique em Detalhes para preencher `Contrato`, `Datas`, `Financeiro` e demais campos obrigatórios do cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
    @@ -1525,6 +1466,198 @@
+ + + + + +
; } +interface BatchExcelIssueDto { + column?: string | null; + message: string; +} + +interface BatchExcelPreviewRowDto { + sourceRowNumber: number; + sourceItem?: number | null; + generatedItemPreview?: number | null; + valid: boolean; + duplicateLinhaInFile?: boolean; + duplicateChipInFile?: boolean; + duplicateLinhaInSystem?: boolean; + duplicateChipInSystem?: boolean; + data: Partial; + errors: BatchExcelIssueDto[]; + warnings: BatchExcelIssueDto[]; +} + +interface BatchExcelPreviewResultDto { + fileName?: string | null; + sheetName?: string | null; + nextItemStart: number; + totalRows: number; + validRows: number; + invalidRows: number; + duplicateRows: number; + canProceed: boolean; + headerErrors: BatchExcelIssueDto[]; + headerWarnings: BatchExcelIssueDto[]; + rows: BatchExcelPreviewRowDto[]; +} + +interface AssignReservaLinesRequestDto { + clienteDestino: string; + usuarioDestino?: string | null; + skilDestino?: string | null; + lineIds: string[]; +} + +interface MoveLinesToReservaRequestDto { + lineIds: string[]; +} + +interface AssignReservaLineItemResultDto { + id: string; + item?: number; + linha?: string | null; + chip?: string | null; + clienteAnterior?: string | null; + clienteNovo?: string | null; + success: boolean; + message: string; +} + +interface AssignReservaLinesResultDto { + requested: number; + updated: number; + failed: number; + items: AssignReservaLineItemResultDto[]; +} + @Component({ standalone: true, @@ -183,6 +247,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { @ViewChild('successToast', { static: false }) successToast!: ElementRef; @ViewChild('excelInput') excelInput!: ElementRef; + @ViewChild('batchExcelInput') batchExcelInput?: ElementRef; @ViewChild('editModal', { static: false }) editModal!: ElementRef; @ViewChild('createModal', { static: false }) createModal!: ElementRef; @@ -203,6 +268,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; return `${apiBase}/lines`; })(); + private readonly templatesApiBase = (() => { + const raw = (environment.apiUrl || '').replace(/\/+$/, ''); + const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + return `${apiBase}/templates`; + })(); loading = false; isAdmin = false; @@ -257,9 +327,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { batchMassInputText = ''; batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO'; batchMassPreview: BatchMassPreviewResult | null = null; + batchExcelPreview: BatchExcelPreviewResultDto | null = null; + batchExcelPreviewLoading = false; + batchExcelTemplateDownloading = false; + batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD'; createBatchValidationByUid: Record = {}; createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; + reservaSelectedLineIds: string[] = []; + reservaTransferOpen = false; + reservaTransferSaving = false; + moveToReservaOpen = false; + moveToReservaSaving = false; + reservaTransferClients: string[] = []; + reservaTransferModel: { clienteDestino: string; usuarioDestino: string; skilDestino: string } = { + clienteDestino: '', + usuarioDestino: '', + skilDestino: '' + }; + reservaTransferLastResult: AssignReservaLinesResultDto | null = null; + moveToReservaLastResult: AssignReservaLinesResultDto | null = null; + detailData: any = null; financeData: any = null; editModel: any = null; @@ -450,6 +538,63 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return rows.slice(0, 5).map((row) => ({ line: row.sourceLineNumber, data: row.data, errors: row.errors })); } + get batchExcelPreviewRowsPreview(): BatchExcelPreviewRowDto[] { + return (this.batchExcelPreview?.rows ?? []).slice(0, 8); + } + + getBatchExcelRowErrorsTitle(row: BatchExcelPreviewRowDto | null | undefined): string { + const errors = row?.errors ?? []; + return errors + .map((e) => `${e?.column ? `${e.column}: ` : ''}${e?.message ?? ''}`.trim()) + .filter(Boolean) + .join(' | '); + } + + getBatchExcelRowPrimaryError(row: BatchExcelPreviewRowDto | null | undefined): string { + const first = row?.errors?.[0]; + if (!first) return ''; + return `${first.column ? `${first.column}: ` : ''}${first.message ?? ''}`.trim(); + } + + get isReservaExpandedGroup(): boolean { + return this.filterSkil === 'RESERVA' && !!(this.expandedGroup ?? '').trim(); + } + + get isExpandedGroupNamedReserva(): boolean { + return (this.expandedGroup ?? '').toString().trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; + } + + get hasGroupLineSelectionTools(): boolean { + return !!(this.expandedGroup ?? '').trim(); + } + + get canMoveSelectedLinesToReserva(): boolean { + return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva; + } + + get reservaSelectedCount(): number { + return this.reservaSelectedLineIds.length; + } + + get reservaSelectedLines(): LineRow[] { + if (!this.hasGroupLineSelectionTools || this.reservaSelectedLineIds.length === 0) return []; + const ids = new Set(this.reservaSelectedLineIds); + return this.groupLines.filter((x) => ids.has(x.id)); + } + + get reservaTransferTargetClientsOptions(): string[] { + const set = new Set(); + for (const c of this.reservaTransferClients) { + const v = (c ?? '').toString().trim(); + if (!v) continue; + if (v.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) continue; + set.add(v); + } + const current = (this.reservaTransferModel?.clienteDestino ?? '').toString().trim(); + if (current && current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0) set.add(current); + return Array.from(set).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })); + } + get isGroupMode(): boolean { return this.viewMode === 'GROUPS'; } @@ -628,7 +773,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { // ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock // ============================================================ private anyModalOpen(): boolean { - return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen); + return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen); } private cleanupModalArtifacts() { @@ -654,6 +799,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.financeOpen = false; this.editOpen = false; this.createOpen = false; + this.reservaTransferOpen = false; + this.moveToReservaOpen = false; this.detailData = null; this.financeData = null; @@ -665,6 +812,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.editingId = null; this.batchDetailOpen = false; this.batchMassPreview = null; + this.batchExcelPreview = null; + this.batchExcelPreviewLoading = false; + this.batchExcelTemplateDownloading = false; + this.reservaTransferSaving = false; + this.moveToReservaSaving = false; + this.reservaTransferLastResult = null; + this.moveToReservaLastResult = null; // Limpa overlays/locks residuais this.cleanupModalArtifacts(); @@ -1377,8 +1531,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { toggleGroup(clientName: string) { if (this.expandedGroup === clientName) { this.expandedGroup = null; + this.groupLines = []; + this.clearReservaSelection(); return; } + this.clearReservaSelection(); this.expandedGroup = clientName; const term = (this.searchTerm ?? '').trim(); @@ -1390,6 +1547,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { fetchGroupLines(clientName: string, search?: string) { const requestVersion = ++this.linesRequestVersion; this.groupLines = []; + this.clearReservaSelection(); this.loadingLines = true; let params = new HttpParams() @@ -1410,6 +1568,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { id: x.id, item: String(x.item ?? ''), linha: x.linha ?? '', + chip: x.chip ?? '', cliente: x.cliente ?? '', usuario: x.usuario ?? '', status: x.status ?? '', @@ -1876,6 +2035,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.batchMassInputText = ''; this.batchMassSeparatorMode = 'AUTO'; this.batchMassPreview = null; + this.batchExcelPreview = null; + this.batchExcelPreviewLoading = false; this.createBatchValidationByUid = {}; this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; this.createSaving = false; @@ -2365,12 +2526,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private recomputeBatchValidation() { const byUid: Record = {}; - const counts = new Map(); + const linhaCounts = new Map(); + const chipCounts = new Map(); this.createBatchLines.forEach((row) => { const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, ''); if (!linhaDigits) return; - counts.set(linhaDigits, (counts.get(linhaDigits) ?? 0) + 1); + linhaCounts.set(linhaDigits, (linhaCounts.get(linhaDigits) ?? 0) + 1); + }); + + this.createBatchLines.forEach((row) => { + const chipDigits = (row?.chip ?? '').toString().replace(/\D/g, ''); + if (!chipDigits) return; + chipCounts.set(chipDigits, (chipCounts.get(chipDigits) ?? 0) + 1); }); let valid = 0; @@ -2381,12 +2549,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const linhaRaw = (row?.linha ?? '').toString().trim(); const chipRaw = (row?.chip ?? '').toString().trim(); const linhaDigits = linhaRaw.replace(/\D/g, ''); + const chipDigits = chipRaw.replace(/\D/g, ''); const errors: string[] = []; if (!linhaRaw) errors.push('Linha obrigatória.'); else if (!linhaDigits) errors.push('Número de linha inválido.'); if (!chipRaw) errors.push('Chip (ICCID) obrigatório.'); + else if (!chipDigits) errors.push('Chip (ICCID) inválido.'); const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim(); const conta = (row?.['conta'] ?? '').toString().trim(); @@ -2402,9 +2572,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.'); if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.'); - const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1; - if (isDuplicate) { + const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1; + const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1; + if (isLinhaDuplicate) { errors.push('Linha duplicada no lote.'); + } + if (isChipDuplicate) { + errors.push('Chip (ICCID) duplicado no lote.'); + } + if (isLinhaDuplicate || isChipDuplicate) { duplicates++; } @@ -2638,6 +2814,372 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }); } + isReservaLineSelected(id: string): boolean { + return this.reservaSelectedLineIds.includes(id); + } + + toggleReservaLineSelection(id: string, checked?: boolean) { + if (!id || !this.hasGroupLineSelectionTools) return; + const exists = this.reservaSelectedLineIds.includes(id); + const shouldSelect = typeof checked === 'boolean' ? checked : !exists; + if (shouldSelect && !exists) { + this.reservaSelectedLineIds = [...this.reservaSelectedLineIds, id]; + return; + } + if (!shouldSelect && exists) { + this.reservaSelectedLineIds = this.reservaSelectedLineIds.filter((x) => x !== id); + } + } + + toggleSelectAllReservaGroupLines() { + if (!this.hasGroupLineSelectionTools) return; + const ids = (this.groupLines ?? []).map((x) => x.id).filter(Boolean); + if (ids.length === 0) { + this.reservaSelectedLineIds = []; + return; + } + if (this.reservaSelectedLineIds.length === ids.length && ids.every((id) => this.reservaSelectedLineIds.includes(id))) { + this.reservaSelectedLineIds = []; + return; + } + this.reservaSelectedLineIds = [...ids]; + } + + clearReservaSelection() { + if (this.reservaSelectedLineIds.length === 0) return; + this.reservaSelectedLineIds = []; + } + + async openReservaTransferModal() { + if (!this.isReservaExpandedGroup) { + await this.showToast('Abra um grupo no filtro Reserva para selecionar e atribuir linhas.'); + return; + } + if (this.reservaSelectedCount <= 0) { + await this.showToast('Selecione ao menos uma linha da Reserva.'); + return; + } + + this.reservaTransferOpen = true; + this.reservaTransferSaving = false; + this.reservaTransferLastResult = null; + this.reservaTransferModel = { + clienteDestino: '', + usuarioDestino: '', + skilDestino: '' + }; + this.cdr.detectChanges(); + + this.loadReservaTransferClients(); + } + + async openMoveToReservaModal() { + if (!this.hasGroupLineSelectionTools || !this.expandedGroup) { + await this.showToast('Abra um grupo para selecionar linhas.'); + return; + } + + if (this.isReservaExpandedGroup || this.isExpandedGroupNamedReserva) { + await this.showToast('Esse grupo já está no contexto da Reserva.'); + return; + } + + if (this.reservaSelectedCount <= 0) { + await this.showToast('Selecione ao menos uma linha para enviar à Reserva.'); + return; + } + + this.moveToReservaOpen = true; + this.moveToReservaSaving = false; + this.moveToReservaLastResult = null; + this.cdr.detectChanges(); + } + + private loadReservaTransferClients() { + this.http.get(`${this.apiBase}/clients`).subscribe({ + next: (clients) => { + this.reservaTransferClients = (clients ?? []).filter((x) => !!(x ?? '').toString().trim()); + this.cdr.detectChanges(); + }, + error: () => { + this.reservaTransferClients = []; + } + }); + } + + async submitReservaTransfer() { + if (this.reservaTransferSaving) return; + + const clienteDestino = (this.reservaTransferModel.clienteDestino ?? '').toString().trim(); + if (!clienteDestino) { + await this.showToast('Informe o cliente de destino.'); + return; + } + + if (clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) { + await this.showToast('O cliente de destino não pode ser RESERVA.'); + return; + } + + if (this.reservaSelectedCount <= 0) { + await this.showToast('Selecione ao menos uma linha da Reserva.'); + return; + } + + const payload: AssignReservaLinesRequestDto = { + clienteDestino, + usuarioDestino: (this.reservaTransferModel.usuarioDestino ?? '').toString().trim() || null, + skilDestino: (this.reservaTransferModel.skilDestino ?? '').toString().trim() || null, + lineIds: [...this.reservaSelectedLineIds] + }; + + this.reservaTransferSaving = true; + + this.http.post(`${this.apiBase}/reserva/assign-client`, payload).subscribe({ + next: async (res) => { + this.reservaTransferSaving = false; + this.reservaTransferLastResult = res; + + const ok = Number(res?.updated ?? 0) || 0; + const failed = Number(res?.failed ?? 0) || 0; + + if (ok > 0) { + this.clearReservaSelection(); + this.reservaTransferOpen = false; + + await this.showToast( + failed > 0 + ? `Transferência concluída com pendências: ${ok} linha(s) atribuída(s), ${failed} falha(s).` + : `${ok} linha(s) da Reserva atribuída(s) com sucesso.` + ); + + if (this.expandedGroup) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + this.fetchGroupLines(this.expandedGroup, useTerm); + } + this.loadGroups(); + this.loadKpis(); + return; + } + + const firstError = res?.items?.find((x) => !x.success)?.message || 'Nenhuma linha foi atribuída.'; + await this.showToast(firstError); + }, + error: async (err: HttpErrorResponse) => { + this.reservaTransferSaving = false; + const msg = (err.error as any)?.message || 'Erro ao atribuir linhas da Reserva.'; + await this.showToast(msg); + } + }); + } + + async submitMoveToReserva() { + if (this.moveToReservaSaving) return; + if (!this.canMoveSelectedLinesToReserva) { + await this.showToast('Selecione linhas de um cliente para enviar à Reserva.'); + return; + } + if (this.reservaSelectedCount <= 0) { + await this.showToast('Selecione ao menos uma linha para enviar à Reserva.'); + return; + } + + const payload: MoveLinesToReservaRequestDto = { + lineIds: [...this.reservaSelectedLineIds] + }; + + this.moveToReservaSaving = true; + + this.http.post(`${this.apiBase}/move-to-reserva`, payload).subscribe({ + next: async (res) => { + this.moveToReservaSaving = false; + this.moveToReservaLastResult = res; + + const ok = Number(res?.updated ?? 0) || 0; + const failed = Number(res?.failed ?? 0) || 0; + + if (ok > 0) { + this.clearReservaSelection(); + this.moveToReservaOpen = false; + + await this.showToast( + failed > 0 + ? `Envio para Reserva concluído com pendências: ${ok} linha(s) enviada(s), ${failed} falha(s).` + : `${ok} linha(s) enviada(s) para a Reserva com sucesso.` + ); + + if (this.expandedGroup) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + this.fetchGroupLines(this.expandedGroup, useTerm); + } + this.loadGroups(); + this.loadKpis(); + return; + } + + const firstError = res?.items?.find((x) => !x.success)?.message || 'Nenhuma linha foi enviada para a Reserva.'; + await this.showToast(firstError); + }, + error: async (err: HttpErrorResponse) => { + this.moveToReservaSaving = false; + const msg = (err.error as any)?.message || 'Erro ao enviar linhas para a Reserva.'; + await this.showToast(msg); + } + }); + } + + async onDownloadBatchExcelTemplate() { + if (this.batchExcelTemplateDownloading) return; + + this.batchExcelTemplateDownloading = true; + const params = new HttpParams().set('_', `${Date.now()}`); + this.http.get(`${this.templatesApiBase}/planilha-geral`, { params, observe: 'response', responseType: 'blob' }).subscribe({ + next: async (res) => { + this.batchExcelTemplateDownloading = false; + + const blob = res.body; + if (!blob) { + await this.showToast('Não foi possível baixar o modelo da planilha.'); + return; + } + + const disposition = res.headers.get('content-disposition') || ''; + const fileName = this.extractDownloadFileName(disposition) || 'MODELO_GERAL_LINEGESTAO.xlsx'; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }, + error: async (err: HttpErrorResponse) => { + this.batchExcelTemplateDownloading = false; + const msg = (err.error as any)?.message || 'Erro ao baixar o modelo da planilha.'; + await this.showToast(msg); + } + }); + } + + private extractDownloadFileName(contentDisposition: string): string | null { + const raw = (contentDisposition ?? '').trim(); + if (!raw) return null; + + const utf8Match = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, '$1')); + } catch { + return utf8Match[1].trim().replace(/^"(.*)"$/, '$1'); + } + } + + const simpleMatch = raw.match(/filename\s*=\s*([^;]+)/i); + if (!simpleMatch?.[1]) return null; + return simpleMatch[1].trim().replace(/^"(.*)"$/, '$1'); + } + + async onImportBatchExcel() { + if (this.createSaving) return; + if (!this.isCreateBatchMode) { + await this.showToast('Ative o modo Lote de Linhas para importar a planilha.'); + return; + } + + if (!this.batchExcelInput?.nativeElement) return; + this.batchExcelInput.nativeElement.value = ''; + this.batchExcelInput.nativeElement.click(); + } + + onBatchExcelSelected(ev: Event) { + const file = (ev.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const form = new FormData(); + form.append('file', file); + + this.batchExcelPreviewLoading = true; + this.batchExcelPreview = null; + + this.http.post(`${this.apiBase}/batch/import-preview`, form).subscribe({ + next: (preview) => { + this.batchExcelPreviewLoading = false; + this.batchExcelPreview = preview; + this.cdr.detectChanges(); + }, + error: async (err: HttpErrorResponse) => { + this.batchExcelPreviewLoading = false; + this.batchExcelPreview = null; + const msg = (err.error as any)?.message || 'Falha ao pré-visualizar a planilha do lote.'; + await this.showToast(msg); + } + }); + } + + clearBatchExcelPreview() { + this.batchExcelPreview = null; + this.batchExcelPreviewLoading = false; + } + + private mapBatchExcelPreviewRowToSeed(row: BatchExcelPreviewRowDto): Partial { + const data = row?.data ?? {}; + return { + ...data, + item: 0, + linha: (data.linha ?? '').toString(), + chip: (data.chip ?? '').toString(), + usuario: (data.usuario ?? '').toString(), + tipoDeChip: (data.tipoDeChip ?? '').toString(), + dataBloqueio: this.isoToDateInput(data.dataBloqueio as any), + dataEntregaOpera: this.isoToDateInput(data.dataEntregaOpera as any), + dataEntregaCliente: this.isoToDateInput(data.dataEntregaCliente as any), + dtEfetivacaoServico: this.isoToDateInput(data.dtEfetivacaoServico as any), + dtTerminoFidelizacao: this.isoToDateInput(data.dtTerminoFidelizacao as any) + }; + } + + async applyBatchExcelPreview(mode: BatchMassApplyMode) { + const preview = this.batchExcelPreview; + if (!preview) { + await this.showToast('Importe uma planilha para gerar a pré-visualização.'); + return; + } + + if ((preview.headerErrors?.length ?? 0) > 0) { + await this.showToast(preview.headerErrors[0]?.message || 'Corrija os erros de cabeçalho antes de aplicar.'); + return; + } + + const validRows = (preview.rows ?? []).filter((row) => row.valid); + if (validRows.length <= 0) { + await this.showToast('Nenhuma linha válida encontrada na planilha para carregar no lote.'); + return; + } + + const parsedRows = validRows.map((row) => + this.createBatchDraftFromSource( + this.createModel, + this.mapBatchExcelPreviewRowToSeed(row), + { keepLinha: true, keepChip: true, copyDetails: true } + ) + ); + + this.createBatchLines = mergeMassRows(this.createBatchLines, parsedRows, mode); + this.selectedBatchLineUid = parsedRows[parsedRows.length - 1]?.uid ?? this.selectedBatchLineUid; + this.batchDetailOpen = this.createBatchLines.length > 0; + this.recomputeBatchValidation(); + + await this.showToast( + mode === 'REPLACE' + ? `${parsedRows.length} linha(s) válida(s) carregada(s) da planilha (substituindo o lote atual).` + : `${parsedRows.length} linha(s) válida(s) adicionada(s) ao lote pela planilha.` + ); + } + private async saveCreateBatch() { const clientError = this.validateCreateClientFields(); if (clientError) { diff --git a/src/app/pages/historico/historico.html b/src/app/pages/historico/historico.html index fcc32b6..e01f1e8 100644 --- a/src/app/pages/historico/historico.html +++ b/src/app/pages/historico/historico.html @@ -87,11 +87,11 @@ [disabled]="loading">
-
- - +
+ +
-
+
{{ formatAction(log.action) }} +
{{ displayEntity(log) }} @@ -164,7 +164,6 @@
- {{ log.entityId }}
LINHA CONTA USUÁRIOPLANOPLANO EFETIVAÇÃO VENCIMENTO TOTAL{{ row.linha }} {{ row.conta || '-' }} {{ row.usuario || '-' }}{{ row.planoContrato || '-' }}{{ row.planoContrato || '-' }} {{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }} @@ -146,8 +146,16 @@ {{ (row.total || 0) | currency:'BRL' }} +
+ {{ getRenewalBadge(row) }} + @@ -236,6 +244,12 @@ {{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
+
+ Renovação + + {{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }} + +
Valor Total {{ (selectedRow?.total || 0) | currency:'BRL' }} diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss index 2a7bcb9..5e41891 100644 --- a/src/app/pages/vigencia/vigencia.scss +++ b/src/app/pages/vigencia/vigencia.scss @@ -286,9 +286,12 @@ .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-wrap { overflow: auto; } +.inner-table-wrap { max-height: 500px; overflow: auto; } .table-modern { - width: 100%; border-collapse: separate; border-spacing: 0; + 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; @@ -301,9 +304,26 @@ .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; } +.td-clip { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} -.actions-col { min-width: 152px; } +.plano-col { + max-width: 220px; +} + +.actions-col { + min-width: 280px; + width: 280px; + text-align: center; + padding-left: 16px !important; + padding-right: 16px !important; +} .action-group { display: flex; @@ -312,6 +332,27 @@ gap: 6px; flex-wrap: nowrap; white-space: nowrap; + width: 100%; + margin: 0 auto; +} + +.renew-chip { + font-size: 0.66rem; + font-weight: 900; + color: #92400e; + background: rgba(245, 158, 11, 0.18); + border: 1px solid rgba(245, 158, 11, 0.38); + border-radius: 999px; + padding: 4px 8px; +} + +.btn-xs { + min-height: 26px; + padding: 4px 7px; + font-size: 0.66rem; + font-weight: 800; + border-radius: 8px; + white-space: nowrap; } .btn-icon { @@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); } .actions-col { - min-width: 120px; + min-width: 210px; + width: 210px; } .action-group { @@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); } .actions-col { - min-width: 106px; + min-width: 190px; + width: 190px; } .action-group { diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index 0160e1e..eebdc2c 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { AuthService } from '../../services/auth.service'; @@ -104,12 +106,14 @@ export class VigenciaComponent implements OnInit, OnDestroy { toastType: ToastType = 'success'; private toastTimer: any = null; private searchTimer: any = null; + private readonly subs = new Subscription(); constructor( private vigenciaService: VigenciaService, private authService: AuthService, private linesService: LinesService, - private planAutoFill: PlanAutoFillService + private planAutoFill: PlanAutoFillService, + private route: ActivatedRoute ) {} ngOnInit(): void { @@ -117,11 +121,13 @@ export class VigenciaComponent implements OnInit, OnDestroy { this.loadClients(); this.loadPlanRules(); this.fetch(1); + this.bindOpenFromNotificationQuery(); } ngOnDestroy(): void { if (this.searchTimer) clearTimeout(this.searchTimer); if (this.toastTimer) clearTimeout(this.toastTimer); + this.subs.unsubscribe(); } setView(mode: ViewMode): void { @@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy { return this.startOfDay(d) >= this.startOfDay(new Date()); } + public isAVencer(dateValue: any): boolean { + if (!dateValue) return false; + const d = this.parseAnyDate(dateValue); + if (!d) return false; + const today = this.startOfDay(new Date()); + const end = this.startOfDay(d); + const days = Math.round((end.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + return days >= 0 && days <= 30; + } + + getRenewalBadge(row: VigenciaRow): string { + if (!row.autoRenewYears) return ''; + return `Auto +${row.autoRenewYears} ano(s)`; + } + public parseAnyDate(value: any): Date | null { if (!value) return null; const d = new Date(value); @@ -273,6 +294,22 @@ export class VigenciaComponent implements OnInit, OnDestroy { if (this.searchTimer) clearTimeout(this.searchTimer); this.fetch(1); } + + scheduleAutoRenew(row: VigenciaRow): void { + if (!row?.id) return; + const years = 2; + + this.vigenciaService.configureAutoRenew(row.id, { years }).subscribe({ + next: () => { + row.autoRenewYears = years; + row.autoRenewReferenceEndDate = row.dtTerminoFidelizacao; + row.autoRenewConfiguredAt = new Date().toISOString(); + this.showToast(`Renovação automática (+${years} ano${years > 1 ? 's' : ''}) programada.`, 'success'); + }, + error: () => this.showToast('Não foi possível programar a renovação automática.', 'danger') + }); + } + openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; } closeDetails() { this.detailsOpen = false; } @@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy { return Number.isNaN(n) ? null : n; } + private bindOpenFromNotificationQuery(): void { + this.subs.add( + this.route.queryParamMap.subscribe((params) => { + const lineId = (params.get('lineId') ?? '').trim(); + const linha = (params.get('linha') ?? '').trim(); + if (!lineId && !linha) return; + + const openMode = (params.get('open') ?? 'edit').trim().toLowerCase(); + if (lineId) { + this.openVigenciaLineById(lineId, openMode); + } else if (linha) { + this.openVigenciaLineByNumber(linha, openMode); + } + }) + ); + } + + private openVigenciaLineById(lineId: string, openMode: string): void { + this.vigenciaService.getById(lineId).subscribe({ + next: (row) => { + if (this.isAdmin && openMode !== 'details') { + this.openEdit(row); + return; + } + this.openDetails(row); + }, + error: () => this.showToast('Não foi possível abrir a linha da vigência pela notificação.', 'danger') + }); + } + + private openVigenciaLineByNumber(linha: string, openMode: string): void { + const onlyDigits = (linha || '').replace(/\D/g, ''); + const lookup = onlyDigits || linha; + if (!lookup) return; + + this.vigenciaService.getVigencia({ + search: lookup, + page: 1, + pageSize: 20, + sortBy: 'item', + sortDir: 'asc' + }).subscribe({ + next: (res) => { + const rows = res?.items ?? []; + const match = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0]; + if (!match) { + this.showToast('Linha da notificação não encontrada na vigência.', 'danger'); + return; + } + + if (this.isAdmin && openMode !== 'details') { + this.openEdit(match); + return; + } + this.openDetails(match); + }, + error: () => this.showToast('Não foi possível localizar a linha da notificação na vigência.', 'danger') + }); + } + handleError(err: HttpErrorResponse, msg: string) { this.loading = false; this.expandedLoading = false; diff --git a/src/app/services/historico.service.ts b/src/app/services/historico.service.ts index 69e9636..2fb0c5c 100644 --- a/src/app/services/historico.service.ts +++ b/src/app/services/historico.service.ts @@ -42,7 +42,7 @@ export interface HistoricoQuery { pageName?: string; action?: AuditAction | string; entity?: string; - userId?: string; + user?: string; search?: string; dateFrom?: string; dateTo?: string; @@ -64,7 +64,7 @@ export class HistoricoService { if (params.pageName) httpParams = httpParams.set('pageName', params.pageName); if (params.action) httpParams = httpParams.set('action', params.action); if (params.entity) httpParams = httpParams.set('entity', params.entity); - if (params.userId) httpParams = httpParams.set('userId', params.userId); + if (params.user) httpParams = httpParams.set('user', params.user); if (params.search) httpParams = httpParams.set('search', params.search); if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index 835ec5f..b78119d 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -4,7 +4,7 @@ import { Observable, Subject, tap } from 'rxjs'; import { environment } from '../../environments/environment'; -export type NotificationTipo = 'AVencer' | 'Vencido'; +export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string; export type NotificationDto = { id: string; diff --git a/src/app/services/vigencia.service.ts b/src/app/services/vigencia.service.ts index 8840fcf..eb65938 100644 --- a/src/app/services/vigencia.service.ts +++ b/src/app/services/vigencia.service.ts @@ -22,6 +22,10 @@ export interface VigenciaRow { planoContrato: string | null; dtEfetivacaoServico: string | null; dtTerminoFidelizacao: string | null; + autoRenewYears?: number | null; + autoRenewReferenceEndDate?: string | null; + autoRenewConfiguredAt?: string | null; + lastAutoRenewedAt?: string | null; total: number | null; createdAt?: string | null; updatedAt?: string | null; @@ -40,6 +44,9 @@ export interface UpdateVigenciaRequest { } export interface CreateVigenciaRequest extends UpdateVigenciaRequest {} +export interface ConfigureVigenciaRenewalRequest { + years: 2; +} export interface VigenciaClientGroup { cliente: string; @@ -118,4 +125,8 @@ export class VigenciaService { remove(id: string): Observable { return this.http.delete(`${this.baseApi}/lines/vigencia/${id}`); } + + configureAutoRenew(id: string, payload: ConfigureVigenciaRenewalRequest): Observable { + return this.http.post(`${this.baseApi}/lines/vigencia/${id}/renew`, payload); + } } From 4dcbfadd2ca9b220b7d7c65de00d7e8e002ca83c Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 27 Feb 2026 16:34:54 -0300 Subject: [PATCH 2/2] Feat: Corrigindo merge --- src/app/app.routes.ts | 14 ++-- src/app/components/header/header.html | 8 +-- src/app/components/header/header.ts | 20 +++--- ...-admin.guard.ts => sysadmin-only.guard.ts} | 6 +- ...n.guard.ts => sysadmin-or-gestor.guard.ts} | 2 +- .../chips-controle-recebidos.html | 16 ++--- .../chips-controle-recebidos.ts | 16 ++--- .../pages/dados-usuarios/dados-usuarios.html | 6 +- .../pages/dados-usuarios/dados-usuarios.ts | 10 +-- src/app/pages/faturamento/faturamento.html | 4 +- src/app/pages/faturamento/faturamento.ts | 8 +-- src/app/pages/geral/geral.html | 66 ++++++++++++++----- src/app/pages/geral/geral.ts | 12 ++-- src/app/pages/login/login.html | 2 +- .../parcelamentos-table.html | 2 +- .../parcelamentos-table.ts | 2 +- .../pages/parcelamentos/parcelamentos.html | 2 +- src/app/pages/parcelamentos/parcelamentos.ts | 6 +- .../system-provision-user.html | 2 +- .../system-provision-user.ts | 10 +-- src/app/pages/vigencia/vigencia.html | 6 +- src/app/pages/vigencia/vigencia.ts | 14 ++-- ...m-admin.service.ts => sysadmin.service.ts} | 2 +- 23 files changed, 133 insertions(+), 103 deletions(-) rename src/app/guards/{system-admin.guard.ts => sysadmin-only.guard.ts} (80%) rename src/app/guards/{admin.guard.ts => sysadmin-or-gestor.guard.ts} (92%) rename src/app/services/{system-admin.service.ts => sysadmin.service.ts} (97%) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ff379d1..51c630c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,8 +8,8 @@ import { Mureg } from './pages/mureg/mureg'; import { Faturamento } from './pages/faturamento/faturamento'; import { authGuard } from './guards/auth.guard'; -import { adminGuard } from './guards/admin.guard'; -import { systemAdminGuard } from './guards/system-admin.guard'; +import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard'; +import { sysadminOnlyGuard } from './guards/sysadmin-only.guard'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; import { TrocaNumero } from './pages/troca-numero/troca-numero'; @@ -29,20 +29,20 @@ export const routes: Routes = [ { path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' }, { path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' }, - { path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], title: 'Faturamento' }, + { path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Faturamento' }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' }, - { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, adminGuard], title: 'Chips Controle Recebidos' }, + { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' }, { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, - { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' }, - { path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' }, + { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' }, + { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'system/fornecer-usuario', component: SystemProvisionUserPage, - canActivate: [authGuard, systemAdminGuard], + canActivate: [authGuard, sysadminOnlyGuard], title: 'Fornecer Usuário', }, diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index 24a3875..4e0f288 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -185,13 +185,13 @@ Perfil
- - -
@@ -537,7 +537,7 @@ Troca de número - + Fornecer usuário
diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index acb4fd4..1e0ad93 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -31,9 +31,8 @@ export class Header implements AfterViewInit, OnDestroy { manageUsersOpen = false; isLoggedHeader = false; isHome = false; - isAdmin = false; + isSysAdmin = false; canViewAll = false; - isSystemAdmin = false; notifications: NotificationDto[] = []; notificationsLoading = false; notificationsError = false; @@ -203,16 +202,14 @@ export class Header implements AfterViewInit, OnDestroy { private syncPermissions() { if (!isPlatformBrowser(this.platformId)) { - this.isAdmin = false; + this.isSysAdmin = false; this.canViewAll = false; - this.isSystemAdmin = false; return; } const isSysAdmin = this.authService.hasRole('sysadmin'); const isGestor = this.authService.hasRole('gestor'); - this.isAdmin = isSysAdmin; + this.isSysAdmin = isSysAdmin; this.canViewAll = isSysAdmin || isGestor; - this.isSystemAdmin = this.authService.hasRole('sysadmin'); } toggleMenu() { @@ -238,13 +235,13 @@ export class Header implements AfterViewInit, OnDestroy { } goToSystemProvisionUser() { - if (!this.isSystemAdmin) return; + if (!this.isSysAdmin) return; this.closeOptions(); this.router.navigate(['/system/fornecer-usuario']); } openCreateUserModal() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.createUserOpen = true; this.closeOptions(); this.resetCreateUserState(); @@ -256,7 +253,7 @@ export class Header implements AfterViewInit, OnDestroy { } openManageUsersModal() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.manageUsersOpen = true; this.closeOptions(); this.resetManageUsersState(); @@ -452,9 +449,8 @@ export class Header implements AfterViewInit, OnDestroy { this.authService.logout(); this.optionsOpen = false; this.notificationsOpen = false; - this.isAdmin = false; + this.isSysAdmin = false; this.canViewAll = false; - this.isSystemAdmin = false; this.router.navigate(['/']); } @@ -616,7 +612,7 @@ export class Header implements AfterViewInit, OnDestroy { this.createUserForm.markAllAsTouched(); return; } - if (!this.isAdmin) { + if (!this.isSysAdmin) { this.createUserForbidden = true; return; } diff --git a/src/app/guards/system-admin.guard.ts b/src/app/guards/sysadmin-only.guard.ts similarity index 80% rename from src/app/guards/system-admin.guard.ts rename to src/app/guards/sysadmin-only.guard.ts index b5fb227..6375a4a 100644 --- a/src/app/guards/system-admin.guard.ts +++ b/src/app/guards/sysadmin-only.guard.ts @@ -4,7 +4,7 @@ import { isPlatformBrowser } from '@angular/common'; import { AuthService } from '../services/auth.service'; -export const systemAdminGuard: CanActivateFn = () => { +export const sysadminOnlyGuard: CanActivateFn = () => { const router = inject(Router); const platformId = inject(PLATFORM_ID); const authService = inject(AuthService); @@ -18,8 +18,8 @@ export const systemAdminGuard: CanActivateFn = () => { return router.parseUrl('/login'); } - const isSystemAdmin = authService.hasRole('sysadmin'); - if (!isSystemAdmin) { + const isSysAdmin = authService.hasRole('sysadmin'); + if (!isSysAdmin) { return router.parseUrl('/dashboard'); } diff --git a/src/app/guards/admin.guard.ts b/src/app/guards/sysadmin-or-gestor.guard.ts similarity index 92% rename from src/app/guards/admin.guard.ts rename to src/app/guards/sysadmin-or-gestor.guard.ts index 276b26a..252bae1 100644 --- a/src/app/guards/admin.guard.ts +++ b/src/app/guards/sysadmin-or-gestor.guard.ts @@ -3,7 +3,7 @@ import { CanActivateFn, Router } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; import { AuthService } from '../services/auth.service'; -export const adminGuard: CanActivateFn = () => { +export const sysadminOrGestorGuard: CanActivateFn = () => { const router = inject(Router); const platformId = inject(PLATFORM_ID); const authService = inject(AuthService); diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html index d65894f..513d6e6 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html @@ -36,14 +36,14 @@
- -
@@ -295,10 +295,10 @@ - - @@ -339,10 +339,10 @@ - - diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts index 455911e..2885265 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts @@ -118,7 +118,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { controleDeleteOpen = false; controleDeleteTarget: ControleRecebidoListDto | null = null; - isAdmin = false; + isSysAdmin = false; constructor( @Inject(PLATFORM_ID) private platformId: object, @@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { ngOnInit(): void { if (!isPlatformBrowser(this.platformId)) return; - this.isAdmin = this.authService.hasRole('sysadmin'); + this.isSysAdmin = this.authService.hasRole('sysadmin'); this.fetchChips(); this.fetchControle(); } @@ -236,7 +236,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openChipCreate() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.chipCreateModel = { id: '', item: null, @@ -278,7 +278,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openChipEdit(row: ChipVirgemListDto) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.service.getChipVirgemById(row.id).subscribe({ next: (data) => { this.chipEditingId = data.id; @@ -319,7 +319,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openChipDelete(row: ChipVirgemListDto) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.chipDeleteTarget = row; this.chipDeleteOpen = true; } @@ -498,7 +498,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openControleCreate() { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.controleCreateModel = { id: '', ano: new Date().getFullYear(), @@ -603,7 +603,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openControleEdit(row: ControleRecebidoListDto) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.service.getControleRecebidoById(row.id).subscribe({ next: (data) => { this.controleEditingId = data.id; @@ -659,7 +659,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { } openControleDelete(row: ControleRecebidoListDto) { - if (!this.isAdmin) return; + if (!this.isSysAdmin) return; this.controleDeleteTarget = row; this.controleDeleteOpen = true; } diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html index 4e8cc2c..2e34ac1 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.html +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -32,7 +32,7 @@ - @@ -153,8 +153,8 @@
- - + +
LINHA USUÁRIO STATUSVENCIMENTOVENCIMENTO AÇÕES
{{ r.item }} {{ r.linha }}
ICCID: {{ r.chip }}
{{ r.usuario || '-' }} {{ statusLabel(r.status) }} {{ r.contrato }}{{ r.contrato }}
- +
{{ r.skil }}{{ r.contrato }}{{ r.contrato }}
- +