Compare commits

...

4 Commits

Author SHA1 Message Date
Leon Nascimento Moreira 875345ea89
Merge pull request #30 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
2026-02-27 16:35:39 -03:00
Eduardo 4dcbfadd2c Feat: Corrigindo merge 2026-02-27 16:34:54 -03:00
Eduardo 43efc1dc85 chore: merge dev 2026-02-27 14:50:18 -03:00
Eduardo 096306e852 Feat: Adição Lote de Linhas 2026-02-27 14:28:50 -03:00
37 changed files with 1630 additions and 373 deletions

View File

@ -57,3 +57,18 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
## Additional Resources ## 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. 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`

12
package-lock.json generated
View File

@ -29,6 +29,7 @@
"@types/bootstrap": "^5.2.10", "@types/bootstrap": "^5.2.10",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"baseline-browser-mapping": "^2.10.0",
"jasmine-core": "~5.9.0", "jasmine-core": "~5.9.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
@ -3732,13 +3733,16 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.32", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/beasties": { "node_modules/beasties": {

View File

@ -43,6 +43,7 @@
"@types/bootstrap": "^5.2.10", "@types/bootstrap": "^5.2.10",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"baseline-browser-mapping": "^2.10.0",
"jasmine-core": "~5.9.0", "jasmine-core": "~5.9.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",

View File

@ -8,8 +8,8 @@ import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento'; import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard'; import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard'; import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard';
import { systemAdminGuard } from './guards/system-admin.guard'; import { sysadminOnlyGuard } from './guards/sysadmin-only.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia'; import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero'; 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: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' }, { 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: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' }, { 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: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' }, { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' }, { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{ {
path: 'system/fornecer-usuario', path: 'system/fornecer-usuario',
component: SystemProvisionUserPage, component: SystemProvisionUserPage,
canActivate: [authGuard, systemAdminGuard], canActivate: [authGuard, sysadminOnlyGuard],
title: 'Fornecer Usuário', title: 'Fornecer Usuário',
}, },

View File

@ -185,13 +185,13 @@
<i class="bi bi-person-circle"></i> Perfil <i class="bi bi-person-circle"></i> Perfil
</button> </button>
<div class="divider"></div> <div class="divider"></div>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openCreateUserModal()"> <button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openCreateUserModal()">
<i class="bi bi-person-plus"></i> Criar novo usuário <i class="bi bi-person-plus"></i> Criar novo usuário
</button> </button>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()"> <button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário <i class="bi bi-people"></i> Editar usuário
</button> </button>
<button type="button" class="options-item" *ngIf="isSystemAdmin" (click)="goToSystemProvisionUser()"> <button type="button" class="options-item" *ngIf="isSysAdmin" (click)="goToSystemProvisionUser()">
<i class="bi bi-shield-lock"></i> Fornecer usuário (cliente) <i class="bi bi-shield-lock"></i> Fornecer usuário (cliente)
</button> </button>
<div class="divider"></div> <div class="divider"></div>
@ -537,7 +537,7 @@
<a *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span> <i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a> </a>
<a *ngIf="isSystemAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="isSysAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-shield-lock-fill"></i> <span>Fornecer usuário</span> <i class="bi bi-shield-lock-fill"></i> <span>Fornecer usuário</span>
</a> </a>
</div> </div>

View File

@ -31,9 +31,8 @@ export class Header implements AfterViewInit, OnDestroy {
manageUsersOpen = false; manageUsersOpen = false;
isLoggedHeader = false; isLoggedHeader = false;
isHome = false; isHome = false;
isAdmin = false; isSysAdmin = false;
canViewAll = false; canViewAll = false;
isSystemAdmin = false;
notifications: NotificationDto[] = []; notifications: NotificationDto[] = [];
notificationsLoading = false; notificationsLoading = false;
notificationsError = false; notificationsError = false;
@ -203,16 +202,14 @@ export class Header implements AfterViewInit, OnDestroy {
private syncPermissions() { private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) { if (!isPlatformBrowser(this.platformId)) {
this.isAdmin = false; this.isSysAdmin = false;
this.canViewAll = false; this.canViewAll = false;
this.isSystemAdmin = false;
return; return;
} }
const isSysAdmin = this.authService.hasRole('sysadmin'); const isSysAdmin = this.authService.hasRole('sysadmin');
const isGestor = this.authService.hasRole('gestor'); const isGestor = this.authService.hasRole('gestor');
this.isAdmin = isSysAdmin; this.isSysAdmin = isSysAdmin;
this.canViewAll = isSysAdmin || isGestor; this.canViewAll = isSysAdmin || isGestor;
this.isSystemAdmin = this.authService.hasRole('sysadmin');
} }
toggleMenu() { toggleMenu() {
@ -238,13 +235,13 @@ export class Header implements AfterViewInit, OnDestroy {
} }
goToSystemProvisionUser() { goToSystemProvisionUser() {
if (!this.isSystemAdmin) return; if (!this.isSysAdmin) return;
this.closeOptions(); this.closeOptions();
this.router.navigate(['/system/fornecer-usuario']); this.router.navigate(['/system/fornecer-usuario']);
} }
openCreateUserModal() { openCreateUserModal() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.createUserOpen = true; this.createUserOpen = true;
this.closeOptions(); this.closeOptions();
this.resetCreateUserState(); this.resetCreateUserState();
@ -256,7 +253,7 @@ export class Header implements AfterViewInit, OnDestroy {
} }
openManageUsersModal() { openManageUsersModal() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.manageUsersOpen = true; this.manageUsersOpen = true;
this.closeOptions(); this.closeOptions();
this.resetManageUsersState(); this.resetManageUsersState();
@ -359,7 +356,10 @@ export class Header implements AfterViewInit, OnDestroy {
} }
getVigenciaLabel(notification: NotificationDto): string { 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 { getVigenciaDate(notification: NotificationDto): string {
@ -373,7 +373,11 @@ export class Header implements AfterViewInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR'); 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 reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference); const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo; if (!parsed) return notification.tipo;
@ -445,9 +449,8 @@ export class Header implements AfterViewInit, OnDestroy {
this.authService.logout(); this.authService.logout();
this.optionsOpen = false; this.optionsOpen = false;
this.notificationsOpen = false; this.notificationsOpen = false;
this.isAdmin = false; this.isSysAdmin = false;
this.canViewAll = false; this.canViewAll = false;
this.isSystemAdmin = false;
this.router.navigate(['/']); this.router.navigate(['/']);
} }
@ -609,7 +612,7 @@ export class Header implements AfterViewInit, OnDestroy {
this.createUserForm.markAllAsTouched(); this.createUserForm.markAllAsTouched();
return; return;
} }
if (!this.isAdmin) { if (!this.isSysAdmin) {
this.createUserForbidden = true; this.createUserForbidden = true;
return; return;
} }

View File

@ -4,7 +4,7 @@ import { isPlatformBrowser } from '@angular/common';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
export const systemAdminGuard: CanActivateFn = () => { export const sysadminOnlyGuard: CanActivateFn = () => {
const router = inject(Router); const router = inject(Router);
const platformId = inject(PLATFORM_ID); const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService); const authService = inject(AuthService);
@ -18,8 +18,8 @@ export const systemAdminGuard: CanActivateFn = () => {
return router.parseUrl('/login'); return router.parseUrl('/login');
} }
const isSystemAdmin = authService.hasRole('sysadmin'); const isSysAdmin = authService.hasRole('sysadmin');
if (!isSystemAdmin) { if (!isSysAdmin) {
return router.parseUrl('/dashboard'); return router.parseUrl('/dashboard');
} }

View File

@ -3,7 +3,7 @@ import { CanActivateFn, Router } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
export const adminGuard: CanActivateFn = () => { export const sysadminOrGestorGuard: CanActivateFn = () => {
const router = inject(Router); const router = inject(Router);
const platformId = inject(PLATFORM_ID); const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService); const authService = inject(AuthService);

View File

@ -36,14 +36,14 @@
<div class="header-actions d-flex gap-2 justify-content-end"> <div class="header-actions d-flex gap-2 justify-content-end">
<button <button
*ngIf="isAdmin && activeTab === 'chips'" *ngIf="isSysAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm" class="btn btn-brand btn-sm"
(click)="openChipCreate()" (click)="openChipCreate()"
> >
<i class="bi bi-plus-circle me-1"></i> Novo Chip <i class="bi bi-plus-circle me-1"></i> Novo Chip
</button> </button>
<button <button
*ngIf="isAdmin && activeTab === 'controle'" *ngIf="isSysAdmin && activeTab === 'controle'"
class="btn btn-brand btn-sm" class="btn btn-brand btn-sm"
(click)="openControleCreate()" (click)="openControleCreate()"
> >
@ -197,10 +197,10 @@
<button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openChipEdit(r); $event.stopPropagation()" title="Editar"> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openChipEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openChipDelete(r); $event.stopPropagation()" title="Excluir"> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openChipDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@ -295,10 +295,10 @@
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar"> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir"> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@ -339,10 +339,10 @@
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes"> <button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar"> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
<i class="bi bi-pencil-square"></i> <i class="bi bi-pencil-square"></i>
</button> </button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir"> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>

View File

@ -118,7 +118,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
controleDeleteOpen = false; controleDeleteOpen = false;
controleDeleteTarget: ControleRecebidoListDto | null = null; controleDeleteTarget: ControleRecebidoListDto | null = null;
isAdmin = false; isSysAdmin = false;
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.fetchChips(); this.fetchChips();
this.fetchControle(); this.fetchControle();
} }
@ -236,7 +236,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openChipCreate() { openChipCreate() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.chipCreateModel = { this.chipCreateModel = {
id: '', id: '',
item: null, item: null,
@ -278,7 +278,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openChipEdit(row: ChipVirgemListDto) { openChipEdit(row: ChipVirgemListDto) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.service.getChipVirgemById(row.id).subscribe({ this.service.getChipVirgemById(row.id).subscribe({
next: (data) => { next: (data) => {
this.chipEditingId = data.id; this.chipEditingId = data.id;
@ -319,7 +319,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openChipDelete(row: ChipVirgemListDto) { openChipDelete(row: ChipVirgemListDto) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.chipDeleteTarget = row; this.chipDeleteTarget = row;
this.chipDeleteOpen = true; this.chipDeleteOpen = true;
} }
@ -498,7 +498,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openControleCreate() { openControleCreate() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.controleCreateModel = { this.controleCreateModel = {
id: '', id: '',
ano: new Date().getFullYear(), ano: new Date().getFullYear(),
@ -603,7 +603,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openControleEdit(row: ControleRecebidoListDto) { openControleEdit(row: ControleRecebidoListDto) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.service.getControleRecebidoById(row.id).subscribe({ this.service.getControleRecebidoById(row.id).subscribe({
next: (data) => { next: (data) => {
this.controleEditingId = data.id; this.controleEditingId = data.id;
@ -659,7 +659,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
} }
openControleDelete(row: ControleRecebidoListDto) { openControleDelete(row: ControleRecebidoListDto) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.controleDeleteTarget = row; this.controleDeleteTarget = row;
this.controleDeleteOpen = true; this.controleDeleteOpen = true;
} }

View File

@ -32,7 +32,7 @@
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading"> <button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar <i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button> </button>
<button *ngIf="isAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()"> <button *ngIf="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário <i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button> </button>
</div> </div>
@ -153,8 +153,8 @@
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -103,7 +103,7 @@ export class DadosUsuarios implements OnInit {
createClientsLoading = false; createClientsLoading = false;
createLinesLoading = false; createLinesLoading = false;
isAdmin = false; isSysAdmin = false;
toastOpen = false; toastOpen = false;
toastMessage = ''; toastMessage = '';
toastType: 'success' | 'danger' = 'success'; toastType: 'success' | 'danger' = 'success';
@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.fetch(1); this.fetch(1);
} }
@ -283,7 +283,7 @@ export class DadosUsuarios implements OnInit {
closeDetails() { this.detailsOpen = false; } closeDetails() { this.detailsOpen = false; }
openEdit(row: UserDataRow) { openEdit(row: UserDataRow) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.service.getById(row.id).subscribe({ this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => { next: (fullData: UserDataRow) => {
this.editingId = fullData.id; this.editingId = fullData.id;
@ -366,7 +366,7 @@ export class DadosUsuarios implements OnInit {
// CREATE // CREATE
// ========================== // ==========================
openCreate() { openCreate() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.resetCreateModel(); this.resetCreateModel();
this.createOpen = true; this.createOpen = true;
this.preloadGeralClients(); this.preloadGeralClients();
@ -532,7 +532,7 @@ export class DadosUsuarios implements OnInit {
} }
openDelete(row: UserDataRow) { openDelete(row: UserDataRow) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.deleteTarget = row; this.deleteTarget = row;
this.deleteOpen = true; this.deleteOpen = true;
} }

View File

@ -286,8 +286,8 @@
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button> <button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -24,6 +24,7 @@ import {
BillingUpdateRequest BillingUpdateRequest
} from '../../services/billing'; } from '../../services/billing';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
interface BillingClientGroup { interface BillingClientGroup {
@ -51,6 +52,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: object, @Inject(PLATFORM_ID) private platformId: object,
private billing: BillingService, private billing: BillingService,
private linesService: LinesService,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private authService: AuthService private authService: AuthService
) {} ) {}
@ -103,9 +105,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
deleteOpen = false; deleteOpen = false;
deleteTarget: BillingItem | null = null; deleteTarget: BillingItem | null = null;
isAdmin = false; isSysAdmin = false;
private searchTimer: any = null; private searchTimer: any = null;
private searchResolvedClients: string[] = [];
private searchResolveVersion = 0;
// cache do ALL // cache do ALL
private allCache: BillingItem[] = []; private allCache: BillingItem[] = [];
@ -160,7 +164,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations(); this.initAnimations();
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
setTimeout(() => { setTimeout(() => {
this.refreshData(true); this.refreshData(true);
@ -351,22 +355,59 @@ export class Faturamento implements AfterViewInit, OnDestroy {
onSearch() { onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer); if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => { this.searchTimer = setTimeout(async () => {
this.page = 1; this.page = 1;
this.expandedGroup = null; this.expandedGroup = null;
this.groupRows = []; this.groupRows = [];
await this.resolveSearchClientsByLineOrChip();
this.refreshData(); this.refreshData();
}, 250); }, 250);
} }
clearSearch() { clearSearch() {
this.searchTerm = ''; this.searchTerm = '';
this.searchResolvedClients = [];
this.page = 1; this.page = 1;
this.expandedGroup = null; this.expandedGroup = null;
this.groupRows = []; this.groupRows = [];
this.refreshData(); this.refreshData();
} }
private isSpecificLineOrChipSearch(term: string): boolean {
const digits = (term ?? '').replace(/\D/g, '');
return digits.length >= 8;
}
private async resolveSearchClientsByLineOrChip(): Promise<void> {
const term = (this.searchTerm ?? '').trim();
const requestVersion = ++this.searchResolveVersion;
if (!term || !this.isSpecificLineOrChipSearch(term)) {
this.searchResolvedClients = [];
return;
}
try {
const response = await new Promise<any>((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 // Data
// -------------------------- // --------------------------
@ -513,8 +554,12 @@ export class Faturamento implements AfterViewInit, OnDestroy {
} }
const term = this.normalizeText(this.searchTerm); const term = this.normalizeText(this.searchTerm);
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x)));
if (term) { 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 // KPIs
@ -669,7 +714,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
} }
onEditar(r: BillingItem) { onEditar(r: BillingItem) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.editingId = r.id; this.editingId = r.id;
this.editModel = { ...r }; this.editModel = { ...r };
this.editOpen = true; this.editOpen = true;
@ -684,7 +729,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
} }
onDelete(r: BillingItem) { onDelete(r: BillingItem) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.deleteTarget = r; this.deleteTarget = r;
this.deleteOpen = true; this.deleteOpen = true;
this.cdr.detectChanges(); this.cdr.detectChanges();

View File

@ -35,13 +35,14 @@
<button <button
type="button" type="button"
class="btn btn-glass btn-sm" class="btn btn-glass btn-sm"
*ngIf="isAdmin" *ngIf="isSysAdmin"
(click)="onImportExcel()" (click)="onImportExcel()"
[disabled]="loading"> [disabled]="loading">
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel <i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
</button> </button>
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" /> <input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
<input #batchExcelInput type="file" class="d-none" accept=".xlsx" (change)="onBatchExcelSelected($event)" />
<button <button
type="button" type="button"
@ -293,21 +294,49 @@
<div class="group-header" (click)="toggleGroup(group.cliente)"> <div class="group-header" (click)="toggleGroup(group.cliente)">
<div class="group-info"> <div class="group-info">
<h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6> <h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6>
<div class="group-tags"> <div class="group-tags">
<span class="tag-pill">{{ group.totalLinhas }} linhas</span> <span class="tag-pill">{{ group.totalLinhas }} linhas</span>
<span class="tag-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} ativas</span> <span class="tag-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} ativas</span>
<span class="tag-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} bloqueadas</span> <span class="tag-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} bloqueadas</span>
</div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div> </div>
</div>
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
</div>
<div class="group-body" *ngIf="expandedGroup === group.cliente"> <div class="group-body" *ngIf="expandedGroup === group.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white"> <div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Gerenciar Grupo</small> <small class="text-muted fw-bold">Gerenciar Grupo</small>
<button class="btn btn-sm btn-add-line-group" *ngIf="!isClientRestricted" (click)="onAddLineToGroup(group.cliente)"> <div class="d-flex align-items-center gap-2 flex-wrap justify-content-end">
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha <ng-container *ngIf="hasGroupLineSelectionTools">
</button> <button class="btn btn-sm btn-glass" type="button" (click)="toggleSelectAllReservaGroupLines()">
<i class="bi bi-check2-square me-1"></i>
{{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }}
</button>
</ng-container>
<ng-container *ngIf="isReservaExpandedGroup">
<button
class="btn btn-sm btn-brand"
type="button"
(click)="openReservaTransferModal()"
[disabled]="reservaSelectedCount === 0"
>
<i class="bi bi-arrow-left-right me-1"></i> Atribuir Selecionadas ({{ reservaSelectedCount }})
</button>
</ng-container>
<ng-container *ngIf="canMoveSelectedLinesToReserva">
<button
class="btn btn-sm btn-send-reserva-group"
type="button"
(click)="openMoveToReservaModal()"
[disabled]="reservaSelectedCount === 0"
>
<i class="bi bi-box-arrow-left me-1"></i> Enviar p/ Reserva ({{ reservaSelectedCount }})
</button>
</ng-container>
<button class="btn btn-sm btn-add-line-group" *ngIf="!isClientRestricted" (click)="onAddLineToGroup(group.cliente)">
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
</button>
</div>
</div> </div>
<!-- ✅ wrapper com classe extra para permitir MAIS ALTURA em notebook/TV via SCSS --> <!-- ✅ wrapper com classe extra para permitir MAIS ALTURA em notebook/TV via SCSS -->
@ -319,6 +348,16 @@
<table class="table table-modern table-modern-responsive align-middle text-center mb-0" *ngIf="!loadingLines"> <table class="table table-modern table-modern-responsive align-middle text-center mb-0" *ngIf="!loadingLines">
<thead> <thead>
<tr> <tr>
<th *ngIf="hasGroupLineSelectionTools" style="width: 52px;">
<input
class="line-select-checkbox"
type="checkbox"
[checked]="groupLines.length > 0 && reservaSelectedCount === groupLines.length"
(click)="$event.stopPropagation()"
(change)="toggleSelectAllReservaGroupLines()"
aria-label="Selecionar todas as linhas do grupo"
/>
</th>
<th>ITEM</th> <th>ITEM</th>
<th>LINHA</th> <th>LINHA</th>
<th>USUÁRIO</th> <th>USUÁRIO</th>
@ -330,20 +369,39 @@
<tbody> <tbody>
<tr *ngFor="let r of groupLines" class="table-row-item"> <tr *ngFor="let r of groupLines" class="table-row-item">
<td *ngIf="hasGroupLineSelectionTools">
<input
class="line-select-checkbox"
type="checkbox"
[checked]="isReservaLineSelected(r.id)"
(click)="$event.stopPropagation()"
(change)="toggleReservaLineSelection(r.id, $any($event.target).checked)"
[attr.aria-label]="'Selecionar linha ' + (r.linha || r.item)"
/>
</td>
<td class="text-muted fw-bold">{{ r.item }}</td> <td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha }}</td>
<td class="fw-black text-blue" [attr.title]="(r.chip || '') ? ('ICCID: ' + r.chip) : ''">
{{ r.linha }}
<div class="small text-muted fw-normal" *ngIf="r.chip">ICCID: {{ r.chip }}</div>
</td>
<td class="text-dark">{{ r.usuario || '-' }}</td> <td class="text-dark">{{ r.usuario || '-' }}</td>
<td> <td>
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span> <span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td> </td>
<td class="text-muted small fw-bold" *ngIf="!isClientRestricted">{{ r.contrato }}</td> <td class="text-muted small fw-bold" *ngIf="!isClientRestricted">{{ r.contrato }}</td>
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<ng-container *ngIf="!isClientRestricted"> <ng-container *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
</ng-container> </ng-container>
</div> </div>
</td> </td>
@ -430,7 +488,7 @@
</div> </div>
</th> </th>
<th class="text-center">AÇÕES</th> <th class="text-center actions-col-main">AÇÕES</th>
</tr> </tr>
</thead> </thead>
@ -462,7 +520,7 @@
<ng-container *ngIf="!isClientRestricted"> <ng-container *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
</ng-container> </ng-container>
</div> </div>
</td> </td>
@ -507,14 +565,14 @@
<!-- Backdrop --> <!-- Backdrop -->
<div <div
class="modal-backdrop-custom" class="modal-backdrop-custom"
*ngIf="detailOpen || financeOpen || editOpen || createOpen" *ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
(click)="closeAllModals()"> (click)="closeAllModals()">
</div> </div>
<!-- Overlay (captura clique fora) --> <!-- Overlay (captura clique fora) -->
<div <div
class="modal-custom" class="modal-custom"
*ngIf="detailOpen || financeOpen || editOpen || createOpen" *ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
(click)="closeAllModals()" (click)="closeAllModals()"
> >
<!-- CREATE MODAL --> <!-- CREATE MODAL -->
@ -914,233 +972,122 @@
e podem ser diferentes entre linhas. e podem ser diferentes entre linhas.
</div> </div>
<div class="batch-mass-input-box"> <div class="batch-mass-input-box mb-3">
<div class="batch-mass-input-head"> <div class="batch-mass-input-head">
<div> <div>
<div class="batch-mass-title"><i class="bi bi-clipboard2-plus me-2"></i>Entrada em Massa</div> <div class="batch-mass-title"><i class="bi bi-file-earmark-excel me-2"></i>Importar Planilha (Colunas da GERAL)</div>
<div class="batch-mass-sub"> <div class="batch-mass-sub">
Cole ou digite várias linhas em sequência. Formato padrão: Use uma planilha Excel com os mesmos cabeçalhos da <strong>GERAL</strong> (não precisa ter uma aba chamada <strong>GERAL</strong>). A coluna <code>ITÉM</code> não é necessária; se vier preenchida, será ignorada e o sistema gera a sequência automaticamente.
<code>linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao</code>
</div> </div>
</div> </div>
<div class="batch-mass-controls"> <div class="d-flex gap-2 align-items-center flex-wrap justify-content-end">
<label class="small fw-bold text-muted mb-0">Separador</label> <button type="button" class="btn btn-sm btn-glass" (click)="onDownloadBatchExcelTemplate()" [disabled]="createSaving || batchExcelTemplateDownloading || batchExcelPreviewLoading">
<select class="form-select form-select-sm" [(ngModel)]="batchMassSeparatorMode" (ngModelChange)="onBatchMassInputChange()"> <span *ngIf="!batchExcelTemplateDownloading"><i class="bi bi-download me-1"></i> Baixar Modelo (GERAL)</span>
<option value="AUTO">Automático</option> <span *ngIf="batchExcelTemplateDownloading"><span class="spinner-border spinner-border-sm me-2"></span> Baixando...</span>
<option value="SEMICOLON">;</option> </button>
<option value="TAB">TAB</option> <button type="button" class="btn btn-sm btn-brand" (click)="onImportBatchExcel()" [disabled]="createSaving || batchExcelPreviewLoading">
<option value="PIPE">|</option> <span *ngIf="!batchExcelPreviewLoading"><i class="bi bi-paperclip me-1"></i> Anexar Excel</span>
</select> <span *ngIf="batchExcelPreviewLoading"><span class="spinner-border spinner-border-sm me-2"></span> Lendo...</span>
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="clearBatchExcelPreview()" [disabled]="createSaving || (!batchExcelPreview && !batchExcelPreviewLoading)">
<i class="bi bi-x-circle me-1"></i> Limpar Prévia
</button>
</div> </div>
</div> </div>
<details class="batch-mass-guide" open> <div class="batch-mass-preview" *ngIf="batchExcelPreview as excelPreview">
<summary>
<span><i class="bi bi-list-ol me-2"></i>Ordem Oficial de Colunas</span>
<small>Use essa ordem para reduzir erro de importação por texto</small>
</summary>
<div class="batch-mass-guide-body">
<div class="batch-mass-guide-list">
<div class="batch-mass-guide-item" *ngFor="let col of batchMassColumnGuide; let i = index">
<span class="pos">{{ i + 1 }}</span>
<span class="meta">
<span class="name">
{{ col.label }} <span class="text-danger" *ngIf="col.required">*</span>
</span>
<span class="hint">
{{ col.canUseDefault ? 'Aceita parâmetro padrão do lote' : 'Preenchimento por linha' }}
</span>
<span class="note" *ngIf="col.note">{{ col.note }}</span>
</span>
</div>
</div>
<div class="batch-mass-guide-note">
Regra: <strong>valor por linha</strong> sobrescreve <strong>parâmetro padrão do lote</strong>. Se um campo
obrigatório ficar vazio, a linha entra como inválida.
</div>
</div>
</details>
<details class="batch-mass-defaults">
<summary>
<span><i class="bi bi-sliders2 me-2"></i>Parâmetros Padrão do Lote (opcional)</span>
<small>Usados quando a coluna não vier na entrada em massa</small>
</summary>
<div class="batch-mass-defaults-body">
<div class="form-grid">
<div class="form-field">
<label>Empresa (Conta)</label>
<app-select
class="form-select"
size="sm"
[options]="contaEmpresaOptions"
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
[(ngModel)]="createModel.contaEmpresa"
(ngModelChange)="onContaEmpresaChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Conta</label>
<app-select
class="form-select"
size="sm"
[options]="contaOptionsForCreate"
[disabled]="!createModel.contaEmpresa"
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="createModel.conta"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field span-2">
<label>Plano Contrato</label>
<app-select
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onPlanoChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Status</label>
<app-select
class="form-select"
size="sm"
[options]="statusOptions"
[(ngModel)]="createModel.status"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Usuário padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Tipo de Chip padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Dt. Efetivação Serviço</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtEfetivacaoServico" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field span-2">
<label>Dt. Término Fidelização</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtTerminoFidelizacao" (ngModelChange)="onBatchMassInputChange()" />
</div>
</div>
</div>
</details>
<textarea
class="form-control batch-mass-textarea"
rows="5"
[(ngModel)]="batchMassInputText"
(ngModelChange)="onBatchMassInputChange()"
placeholder="Exemplo:
11999999999;8955000000000000001;João;eSIM;SMART EMPRESAS 6GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-01;2027-01-01
11999999998;8955000000000000002;Maria;Físico;SMART EMPRESAS 10GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-02;2027-01-02"
></textarea>
<div class="batch-mass-actions">
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassExample()" [disabled]="createSaving">
<i class="bi bi-stars me-1"></i> Usar Exemplo
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassHeaderTemplate()" [disabled]="createSaving">
<i class="bi bi-type me-1"></i> Usar Cabeçalho
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="previewBatchMassInput()" [disabled]="createSaving">
<i class="bi bi-eye me-1"></i> Pré-visualizar
</button>
<button
type="button"
class="btn btn-sm btn-brand"
(click)="applyBatchMassInput('ADD')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-plus-circle me-1"></i> Adicionar ao Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass"
(click)="applyBatchMassInput('REPLACE')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-arrow-repeat me-1"></i> Substituir Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass text-danger"
(click)="clearBatchMassInput()"
[disabled]="createSaving || (!batchMassInputText && !batchMassHasPreview)"
>
<i class="bi bi-eraser me-1"></i> Limpar Campo
</button>
</div>
<div class="batch-mass-preview" *ngIf="batchMassHasPreview">
<div class="batch-mass-preview-pills"> <div class="batch-mass-preview-pills">
<span class="summary-pill total">Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }}</span> <span class="summary-pill total">Aba: {{ excelPreview.sheetName || 'GERAL' }}</span>
<span class="summary-pill ok">Válidas: {{ batchMassPreview?.validRows || 0 }}</span> <span class="summary-pill total">Linhas lidas: {{ excelPreview.totalRows || 0 }}</span>
<span class="summary-pill warn" *ngIf="(batchMassPreview?.invalidRows || 0) > 0"> <span class="summary-pill ok">Válidas: {{ excelPreview.validRows || 0 }}</span>
Inválidas: {{ batchMassPreview?.invalidRows || 0 }} <span class="summary-pill warn" *ngIf="(excelPreview.invalidRows || 0) > 0">Inválidas: {{ excelPreview.invalidRows || 0 }}</span>
</span> <span class="summary-pill dup" *ngIf="(excelPreview.duplicateRows || 0) > 0">Duplicadas: {{ excelPreview.duplicateRows || 0 }}</span>
<span class="summary-pill dup" *ngIf="(batchMassPreview?.duplicateRows || 0) > 0"> <span class="summary-pill" *ngIf="excelPreview.nextItemStart > 0">Próx. ITÉM (sistema): {{ excelPreview.nextItemStart }}</span>
Duplicadas: {{ batchMassPreview?.duplicateRows || 0 }}
</span>
<span class="summary-pill" *ngIf="batchMassPreview">Separador: {{ batchMassSeparatorLabel }}</span>
<span class="summary-pill" *ngIf="batchMassPreview?.hasHeader">Com cabeçalho</span>
</div> </div>
<div class="batch-mass-preview-errors" *ngIf="(batchMassPreview?.parseErrors?.length || 0) > 0"> <div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerErrors?.length || 0) > 0">
<div class="fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Erros de parsing</div> <strong>Erros de cabeçalho/estrutura</strong>
<ul> <ul class="mb-0 mt-1">
<li *ngFor="let err of batchMassPreview?.parseErrors">{{ err }}</li> <li *ngFor="let err of excelPreview.headerErrors">
<strong *ngIf="err.column">{{ err.column }}:</strong> {{ err.message }}
</li>
</ul> </ul>
</div> </div>
<div class="batch-mass-preview-table-wrap" *ngIf="(batchMassPreview?.recognizedRows || 0) > 0"> <div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerWarnings?.length || 0) > 0">
<strong>Avisos</strong>
<ul class="mb-0 mt-1">
<li *ngFor="let warn of excelPreview.headerWarnings">
<strong *ngIf="warn.column">{{ warn.column }}:</strong> {{ warn.message }}
</li>
</ul>
</div>
<div class="batch-mass-actions mt-2">
<button
type="button"
class="btn btn-sm btn-brand"
(click)="applyBatchExcelPreview('ADD')"
[disabled]="createSaving || batchExcelPreviewLoading || !excelPreview.canProceed"
>
<i class="bi bi-plus-circle me-1"></i> Adicionar Linhas Válidas ao Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass"
(click)="applyBatchExcelPreview('REPLACE')"
[disabled]="createSaving || batchExcelPreviewLoading || !excelPreview.canProceed"
>
<i class="bi bi-arrow-repeat me-1"></i> Substituir Lote com Linhas Válidas
</button>
</div>
<div class="batch-mass-preview-table-wrap mt-2" *ngIf="(excelPreview.rows.length || 0) > 0">
<table class="batch-mass-preview-table"> <table class="batch-mass-preview-table">
<thead> <thead>
<tr> <tr>
<th>Linha origem</th> <th>Planilha</th>
<th>ITÉM (origem)</th>
<th>ITÉM (sistema)</th>
<th>Linha</th> <th>Linha</th>
<th>Chip</th> <th>Chip</th>
<th>Plano</th>
<th>Status</th>
<th>Conta</th> <th>Conta</th>
<th>Status</th>
<th>Validação</th> <th>Validação</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let row of batchMassPreviewRowsPreview"> <tr *ngFor="let row of batchExcelPreviewRowsPreview">
<td>#{{ row.line }}</td> <td>#{{ row.sourceRowNumber }}</td>
<td>{{ row.data['linha'] || '-' }}</td> <td>{{ row.sourceItem ?? '-' }}</td>
<td>{{ row.data['chip'] || '-' }}</td> <td>{{ row.generatedItemPreview ?? '-' }}</td>
<td>{{ row.data['planoContrato'] || '-' }}</td> <td>{{ row.data.linha || '-' }}</td>
<td>{{ row.data['status'] || '-' }}</td> <td>{{ row.data.chip || '-' }}</td>
<td>{{ row.data['conta'] || '-' }}</td> <td>{{ row.data.conta || '-' }}</td>
<td> <td>{{ row.data.status || '-' }}</td>
<span class="batch-row-valid" *ngIf="row.errors.length === 0"><i class="bi bi-check-circle-fill"></i> OK</span> <td class="validation-cell">
<div class="batch-row-errors-compact" *ngIf="row.errors.length > 0" [attr.title]="row.errors.join(' | ')"> <div class="batch-row-valid" *ngIf="(row.errors.length || 0) === 0">
<div class="batch-row-error-main">{{ row.errors[0] }}</div> <i class="bi bi-check-circle-fill"></i> OK
<div class="batch-row-more" *ngIf="row.errors.length > 1">+{{ row.errors.length - 1 }} pendência(s)</div> </div>
<div
class="batch-row-errors-compact"
*ngIf="(row.errors.length || 0) > 0"
[attr.title]="getBatchExcelRowErrorsTitle(row)"
>
<div class="batch-row-error-main">
{{ getBatchExcelRowPrimaryError(row) }}
</div>
<div class="batch-row-more" *ngIf="(row.errors.length || 0) > 1">
+{{ (row.errors.length || 0) - 1 }} pendência(s)
</div>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="batch-mass-preview-foot" *ngIf="(batchMassPreview?.recognizedRows || 0) > 5"> <div class="batch-mass-preview-foot" *ngIf="(excelPreview.rows.length || 0) > (batchExcelPreviewRowsPreview.length || 0)">
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.
</div> </div>
</div> </div>
</div> </div>
@ -1167,8 +1114,8 @@
</div> </div>
<div class="batch-lines-empty" *ngIf="createBatchCount === 0"> <div class="batch-lines-empty" *ngIf="createBatchCount === 0">
Nenhuma linha no lote ainda. Use a <strong>Entrada em Massa</strong> acima para colar/digitar as linhas em Nenhuma linha no lote ainda. Use a <strong>importação por planilha</strong> acima para pré-visualizar e
sequência e carregá-las na grade. carregar as linhas na grade.
</div> </div>
<div class="batch-editor-layout" *ngIf="createBatchCount > 0"> <div class="batch-editor-layout" *ngIf="createBatchCount > 0">
@ -1279,7 +1226,7 @@
<div class="batch-selected-hint"> <div class="batch-selected-hint">
<i class="bi bi-cursor-fill"></i> <i class="bi bi-cursor-fill"></i>
Após carregar o lote pela <strong>Entrada em Massa</strong>, selecione uma linha e clique em Após carregar o lote pela <strong>importação da planilha</strong>, selecione uma linha e clique em
<strong>Detalhes</strong> para preencher `Contrato`, `Datas`, `Financeiro` e demais campos obrigatórios do <strong>Detalhes</strong> 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. cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
</div> </div>
@ -1532,6 +1479,198 @@
</div> </div>
</div> </div>
<!-- MOVE TO RESERVA MODAL -->
<div
*ngIf="moveToReservaOpen"
class="modal-card modal-lg modal-move-reserva"
(click)="$event.stopPropagation()"
>
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg warning"><i class="bi bi-box-arrow-left"></i></span>
Enviar Linhas para Reserva
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="moveToReservaSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-sm btn-send-reserva-group" (click)="submitMoveToReserva()" [disabled]="moveToReservaSaving || reservaSelectedCount === 0">
<span *ngIf="!moveToReservaSaving"><i class="bi bi-check2-circle me-1"></i> Confirmar Envio</span>
<span *ngIf="moveToReservaSaving"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-info-circle me-2"></i>Confirmação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="small text-muted mb-2">
As linhas selecionadas serão movidas para a <strong>Reserva</strong> e ficarão disponíveis para reatribuição.
</div>
<div class="reserva-confirmation-pills">
<div class="summary-pill total">Cliente: {{ expandedGroup || '-' }}</div>
<div class="summary-pill warn">Selecionadas: {{ reservaSelectedCount }}</div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-list-check me-2"></i>Linhas Selecionadas ({{ reservaSelectedCount }})</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="table-wrap inner-table-wrap" style="max-height: 520px;">
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CHIP (ICCID)</th>
<th>USUÁRIO</th>
</tr>
</thead>
<tbody>
<tr *ngIf="reservaSelectedLines.length === 0">
<td colspan="4" class="text-muted py-3">Nenhuma linha selecionada.</td>
</tr>
<tr *ngFor="let r of reservaSelectedLines">
<td>{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha || '-' }}</td>
<td>{{ r.chip || '-' }}</td>
<td>{{ r.usuario || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- RESERVA TRANSFER MODAL -->
<div
*ngIf="reservaTransferOpen"
class="modal-card modal-lg modal-reserva-transfer"
(click)="$event.stopPropagation()"
>
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-arrow-left-right"></i></span>
Atribuir Linhas da Reserva
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="reservaTransferSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="submitReservaTransfer()" [disabled]="reservaTransferSaving || reservaSelectedCount === 0">
<span *ngIf="!reservaTransferSaving"><i class="bi bi-check2-circle me-1"></i> Confirmar Atribuição</span>
<span *ngIf="reservaTransferSaving"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i>Destino da Atribuição</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente de destino <span class="text-danger">*</span></label>
<app-select
class="form-select"
size="sm"
[options]="reservaTransferTargetClientsOptions"
[placeholder]="'Selecione o cliente'"
[(ngModel)]="reservaTransferModel.clienteDestino"
></app-select>
</div>
<div class="form-field span-2">
<label>Usuário (opcional)</label>
<input
class="form-control form-control-sm"
[(ngModel)]="reservaTransferModel.usuarioDestino"
placeholder="Se informado, substitui o usuário nas linhas selecionadas"
/>
</div>
<div class="form-field span-2">
<label>Skil (opcional)</label>
<app-select
class="form-select"
size="sm"
[options]="skilOptions"
[placeholder]="'Manter/inferir automaticamente'"
[(ngModel)]="reservaTransferModel.skilDestino"
></app-select>
</div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-list-check me-2"></i>Linhas Selecionadas ({{ reservaSelectedCount }})</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="table-wrap inner-table-wrap" style="max-height: 460px;">
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CHIP (ICCID)</th>
<th>USUÁRIO</th>
</tr>
</thead>
<tbody>
<tr *ngIf="reservaSelectedLines.length === 0">
<td colspan="4" class="text-muted py-3">Nenhuma linha selecionada.</td>
</tr>
<tr *ngFor="let r of reservaSelectedLines">
<td>{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha || '-' }}</td>
<td>{{ r.chip || '-' }}</td>
<td>{{ r.usuario || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="small text-muted mt-2">
Somente linhas que ainda estiverem aptas na <strong>Reserva</strong> serão atribuídas. O backend retorna sucesso/erro por linha.
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- DETAIL MODAL --> <!-- DETAIL MODAL -->
<div <div
*ngIf="detailOpen" *ngIf="detailOpen"

View File

@ -336,6 +336,118 @@
inset 0 1px 0 rgba(255,255,255,0.16); inset 0 1px 0 rgba(255,255,255,0.16);
} }
} }
.btn-send-reserva-group {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.42rem 0.85rem;
line-height: 1;
white-space: nowrap;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
background:
linear-gradient(135deg, rgba(3, 15, 170, 0.96), rgba(227, 61, 207, 0.9));
box-shadow:
0 10px 22px rgba(3, 15, 170, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease, opacity 0.18s ease;
i {
font-weight: 900;
}
&:hover:not(:disabled) {
color: #fff;
transform: translateY(-1px);
filter: saturate(1.05) brightness(1.02);
box-shadow:
0 14px 26px rgba(227, 61, 207, 0.16),
0 8px 18px rgba(3, 15, 170, 0.14);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24),
0 12px 24px rgba(227, 61, 207, 0.14);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow:
0 8px 16px rgba(3, 15, 170, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: 0 6px 14px rgba(17, 18, 20, 0.12);
}
}
.line-select-checkbox {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 6px;
border: 1.5px solid rgba(3, 15, 170, 0.35);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 246, 255, 0.95));
display: inline-grid;
place-content: center;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.12s ease;
position: relative;
vertical-align: middle;
&::after {
content: "";
width: 5px;
height: 9px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg) scale(0);
transform-origin: center;
transition: transform 0.16s ease;
margin-top: -1px;
}
&:hover {
border-color: rgba(227, 61, 207, 0.65);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
}
&:checked {
border-color: rgba(3, 15, 170, 0.95);
background: linear-gradient(135deg, rgba(3, 15, 170, 0.98), rgba(227, 61, 207, 0.95));
box-shadow:
0 0 0 3px rgba(3, 15, 170, 0.16),
0 4px 10px rgba(3, 15, 170, 0.24);
}
&:checked::after {
transform: rotate(45deg) scale(1);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24);
}
&:active {
transform: scale(0.96);
}
}
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
/* Inner Table Destravada */ /* Inner Table Destravada */
@ -351,6 +463,22 @@
.action-group { display: flex; justify-content: center; gap: 8px; } .action-group { display: flex; justify-content: center; gap: 8px; }
.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); } &.danger:hover { color: var(--danger-text); background: var(--danger-bg); } } .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); } &.danger:hover { color: var(--danger-text); background: var(--danger-bg); } }
/* Evita corte dos botões na última coluna de ações (grupos e tabela) */
.table-modern.table-modern-responsive th.actions-col-main,
.table-modern.table-modern-responsive td.actions-col-main {
min-width: 176px;
}
.table-modern.table-modern-responsive td.actions-col-main {
overflow: visible !important;
text-overflow: clip !important;
white-space: nowrap;
}
.table-modern.table-modern-responsive td.actions-col-main .action-group {
flex-wrap: nowrap;
}
/* Footer */ /* 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; } } .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-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); } }
@ -378,6 +506,54 @@
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; } .modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; } .modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); } .modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
.modal-card.modal-move-reserva {
width: min(1520px, 99vw);
max-height: 94vh;
.details-dashboard {
grid-template-columns: minmax(420px, 0.9fr) minmax(700px, 1.45fr);
gap: 16px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.modal-body {
padding: 20px 22px;
}
.reserva-confirmation-pills {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
.summary-pill {
margin: 0;
white-space: normal;
line-height: 1.25;
}
}
}
.modal-card.modal-reserva-transfer {
width: min(1480px, 99vw);
max-height: 94vh;
.details-dashboard {
grid-template-columns: minmax(420px, 0.95fr) minmax(560px, 1.25fr);
gap: 16px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.modal-body {
padding: 20px 22px;
}
}
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */

View File

@ -45,6 +45,7 @@ interface LineRow {
id: string; id: string;
item: string; item: string;
linha: string; linha: string;
chip?: string;
cliente: string; cliente: string;
usuario: string; usuario: string;
status: string; status: string;
@ -63,6 +64,7 @@ interface ApiLineList {
id: string; id: string;
item: number; item: number;
linha: string | null; linha: string | null;
chip?: string | null;
cliente: string | null; cliente: string | null;
usuario: string | null; usuario: string | null;
vencConta: string | null; vencConta: string | null;
@ -170,6 +172,68 @@ interface CreateMobileLinesBatchResponse {
}>; }>;
} }
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<CreateMobileLineRequest>;
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({ @Component({
standalone: true, standalone: true,
@ -183,6 +247,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('successToast', { static: false }) successToast!: ElementRef; @ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>; @ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ViewChild('batchExcelInput') batchExcelInput?: ElementRef<HTMLInputElement>;
@ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>; @ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>;
@ViewChild('createModal', { static: false }) createModal!: ElementRef<HTMLElement>; @ViewChild('createModal', { static: false }) createModal!: ElementRef<HTMLElement>;
@ -203,8 +268,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`; 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; loading = false;
isAdmin = false; isSysAdmin = false;
isGestor = false; isGestor = false;
isClientRestricted = false; isClientRestricted = false;
@ -259,9 +329,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
batchMassInputText = ''; batchMassInputText = '';
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO'; batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
batchMassPreview: BatchMassPreviewResult | null = null; batchMassPreview: BatchMassPreviewResult | null = null;
batchExcelPreview: BatchExcelPreviewResultDto | null = null;
batchExcelPreviewLoading = false;
batchExcelTemplateDownloading = false;
batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD';
createBatchValidationByUid: Record<number, BatchLineValidation> = {}; createBatchValidationByUid: Record<number, BatchLineValidation> = {};
createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; 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; detailData: any = null;
financeData: any = null; financeData: any = null;
editModel: any = null; editModel: any = null;
@ -452,6 +540,63 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return rows.slice(0, 5).map((row) => ({ line: row.sourceLineNumber, data: row.data, errors: row.errors })); 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<string>();
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 { get isGroupMode(): boolean {
return this.viewMode === 'GROUPS'; return this.viewMode === 'GROUPS';
} }
@ -546,9 +691,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return; if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.isGestor = this.authService.hasRole('gestor'); this.isGestor = this.authService.hasRole('gestor');
this.isClientRestricted = !(this.isAdmin || this.isGestor); this.isClientRestricted = !(this.isSysAdmin || this.isGestor);
if (this.isClientRestricted) { if (this.isClientRestricted) {
this.filterSkil = 'ALL'; this.filterSkil = 'ALL';
@ -643,7 +788,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock // ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
// ============================================================ // ============================================================
private anyModalOpen(): boolean { 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() { private cleanupModalArtifacts() {
@ -669,6 +814,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.financeOpen = false; this.financeOpen = false;
this.editOpen = false; this.editOpen = false;
this.createOpen = false; this.createOpen = false;
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
this.detailData = null; this.detailData = null;
this.financeData = null; this.financeData = null;
@ -680,6 +827,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.editingId = null; this.editingId = null;
this.batchDetailOpen = false; this.batchDetailOpen = false;
this.batchMassPreview = null; 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 // Limpa overlays/locks residuais
this.cleanupModalArtifacts(); this.cleanupModalArtifacts();
@ -1399,8 +1553,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleGroup(clientName: string) { toggleGroup(clientName: string) {
if (this.expandedGroup === clientName) { if (this.expandedGroup === clientName) {
this.expandedGroup = null; this.expandedGroup = null;
this.groupLines = [];
this.clearReservaSelection();
return; return;
} }
this.clearReservaSelection();
this.expandedGroup = clientName; this.expandedGroup = clientName;
const term = (this.searchTerm ?? '').trim(); const term = (this.searchTerm ?? '').trim();
@ -1412,6 +1569,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
fetchGroupLines(clientName: string, search?: string) { fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion; const requestVersion = ++this.linesRequestVersion;
this.groupLines = []; this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true; this.loadingLines = true;
let params = new HttpParams() let params = new HttpParams()
@ -1432,6 +1590,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
id: x.id, id: x.id,
item: String(x.item ?? ''), item: String(x.item ?? ''),
linha: x.linha ?? '', linha: x.linha ?? '',
chip: x.chip ?? '',
cliente: x.cliente ?? '', cliente: x.cliente ?? '',
usuario: x.usuario ?? '', usuario: x.usuario ?? '',
status: x.status ?? '', status: x.status ?? '',
@ -1601,7 +1760,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async onImportExcel() { async onImportExcel() {
if (!this.isAdmin) { if (!this.isSysAdmin) {
await this.showToast('Você não tem permissão para importar planilha.'); await this.showToast('Você não tem permissão para importar planilha.');
return; return;
} }
@ -1612,7 +1771,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
onExcelSelected(ev: Event) { onExcelSelected(ev: Event) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
const file = (ev.target as HTMLInputElement).files?.[0]; const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
@ -1802,7 +1961,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async onRemover(r: LineRow, fromGroup = false) { async onRemover(r: LineRow, fromGroup = false) {
if (!this.isAdmin) { if (!this.isSysAdmin) {
await this.showToast('Apenas sysadmin pode remover linhas.'); await this.showToast('Apenas sysadmin pode remover linhas.');
return; return;
} }
@ -1913,6 +2072,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.batchMassInputText = ''; this.batchMassInputText = '';
this.batchMassSeparatorMode = 'AUTO'; this.batchMassSeparatorMode = 'AUTO';
this.batchMassPreview = null; this.batchMassPreview = null;
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
this.createBatchValidationByUid = {}; this.createBatchValidationByUid = {};
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 }; this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
this.createSaving = false; this.createSaving = false;
@ -2402,12 +2563,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private recomputeBatchValidation() { private recomputeBatchValidation() {
const byUid: Record<number, BatchLineValidation> = {}; const byUid: Record<number, BatchLineValidation> = {};
const counts = new Map<string, number>(); const linhaCounts = new Map<string, number>();
const chipCounts = new Map<string, number>();
this.createBatchLines.forEach((row) => { this.createBatchLines.forEach((row) => {
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, ''); const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
if (!linhaDigits) return; 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; let valid = 0;
@ -2418,12 +2586,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const linhaRaw = (row?.linha ?? '').toString().trim(); const linhaRaw = (row?.linha ?? '').toString().trim();
const chipRaw = (row?.chip ?? '').toString().trim(); const chipRaw = (row?.chip ?? '').toString().trim();
const linhaDigits = linhaRaw.replace(/\D/g, ''); const linhaDigits = linhaRaw.replace(/\D/g, '');
const chipDigits = chipRaw.replace(/\D/g, '');
const errors: string[] = []; const errors: string[] = [];
if (!linhaRaw) errors.push('Linha obrigatória.'); if (!linhaRaw) errors.push('Linha obrigatória.');
else if (!linhaDigits) errors.push('Número de linha inválido.'); else if (!linhaDigits) errors.push('Número de linha inválido.');
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.'); if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
else if (!chipDigits) errors.push('Chip (ICCID) inválido.');
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim(); const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
const conta = (row?.['conta'] ?? '').toString().trim(); const conta = (row?.['conta'] ?? '').toString().trim();
@ -2439,9 +2609,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.'); if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.');
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.'); if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1; const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1;
if (isDuplicate) { const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1;
if (isLinhaDuplicate) {
errors.push('Linha duplicada no lote.'); errors.push('Linha duplicada no lote.');
}
if (isChipDuplicate) {
errors.push('Chip (ICCID) duplicado no lote.');
}
if (isLinhaDuplicate || isChipDuplicate) {
duplicates++; duplicates++;
} }
@ -2675,6 +2851,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<string[]>(`${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<AssignReservaLinesResultDto>(`${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<AssignReservaLinesResultDto>(`${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<BatchExcelPreviewResultDto>(`${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<CreateBatchLineDraft> {
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() { private async saveCreateBatch() {
const clientError = this.validateCreateClientFields(); const clientError = this.validateCreateClientFields();
if (clientError) { if (clientError) {

View File

@ -87,11 +87,11 @@
[disabled]="loading"> [disabled]="loading">
</app-select> </app-select>
</div> </div>
<div class="filter-field"> <div class="filter-field filter-user">
<label>Usuário (ID)</label> <label>Usuário</label>
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" /> <input type="text" placeholder="Nome ou e-mail do usuário" [(ngModel)]="filterUser" [disabled]="loading" />
</div> </div>
<div class="filter-field"> <div class="filter-field filter-search">
<label>Busca geral</label> <label>Busca geral</label>
<div class="input-group input-group-sm search-group"> <div class="input-group input-group-sm search-group">
<span class="input-group-text"> <span class="input-group-text">
@ -150,7 +150,7 @@
<td> <td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span> <span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td> </td>
<td> <td class="entity-col">
<div class="entity-cell"> <div class="entity-cell">
<div class="entity-label td-clip" [title]="displayEntity(log)"> <div class="entity-label td-clip" [title]="displayEntity(log)">
{{ displayEntity(log) }} {{ displayEntity(log) }}
@ -164,7 +164,6 @@
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i> <i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
</button> </button>
</div> </div>
<small class="entity-id" *ngIf="log.entityId">{{ log.entityId }}</small>
</td> </td>
</tr> </tr>
<tr class="details-row" *ngIf="expandedLogId === log.id"> <tr class="details-row" *ngIf="expandedLogId === log.id">

View File

@ -214,6 +214,7 @@
.filter-field { .filter-field {
display: grid; display: grid;
gap: 6px; gap: 6px;
min-width: 0;
label { label {
font-size: 11px; font-size: 11px;
@ -224,6 +225,9 @@
} }
input { input {
width: 100%;
max-width: 100%;
min-width: 0;
height: 40px; height: 40px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15); border: 1px solid rgba(15, 23, 42, 0.15);
@ -244,11 +248,25 @@
grid-column: span 2; grid-column: span 2;
} }
.filter-user {
min-width: 0;
width: 100%;
input {
width: 100%;
max-width: 100%;
}
}
.search-group { .search-group {
max-width: 270px; width: 100%;
max-width: 100%;
min-width: 0;
min-height: 40px;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
background: #fff; background: #fff;
border: 1px solid rgba(17, 18, 20, 0.15); border: 1px solid rgba(17, 18, 20, 0.15);
@ -262,6 +280,7 @@
} }
.input-group-text { .input-group-text {
flex: 0 0 auto;
background: transparent; background: transparent;
border: none; border: none;
color: rgba(17, 18, 20, 0.5); color: rgba(17, 18, 20, 0.5);
@ -272,10 +291,13 @@
} }
.form-control { .form-control {
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: none; border: none;
background: transparent; background: transparent;
height: auto; height: 40px;
padding: 10px 0; padding: 0 8px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text); color: var(--text);
box-shadow: none; box-shadow: none;
@ -285,6 +307,7 @@
} }
.btn-clear { .btn-clear {
flex: 0 0 auto;
border: none; border: none;
background: transparent; background: transparent;
color: rgba(17, 18, 20, 0.45); color: rgba(17, 18, 20, 0.45);
@ -399,7 +422,8 @@
.table-modern th:nth-child(5), .table-modern th:nth-child(5),
.table-modern td:nth-child(5) { .table-modern td:nth-child(5) {
text-align: left; text-align: center;
min-width: 240px;
} }
.table-modern th:nth-child(2), .table-modern th:nth-child(2),
@ -447,13 +471,15 @@
.entity-cell { .entity-cell {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 10px; gap: 8px;
} }
.entity-label { .entity-label {
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
text-align: center;
max-width: 300px;
} }
.entity-id { .entity-id {
@ -677,10 +703,10 @@
.entity-cell { .entity-cell {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: center;
gap: 6px; 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; } .expand-btn { align-self: center; flex-shrink: 0; }
} }
@ -870,18 +896,14 @@
} }
.entity-cell { .entity-cell {
justify-content: flex-start; justify-content: center;
gap: 6px; gap: 6px;
} }
.entity-label { .entity-label {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
} text-align: center;
.entity-id {
margin-top: 2px;
line-height: 1.2;
} }
.details-row td { .details-row td {

View File

@ -36,7 +36,7 @@ export class Historico implements OnInit {
filterPageName = ''; filterPageName = '';
filterAction = ''; filterAction = '';
filterUserId = ''; filterUser = '';
filterSearch = ''; filterSearch = '';
dateFrom = ''; dateFrom = '';
dateTo = ''; dateTo = '';
@ -84,7 +84,7 @@ export class Historico implements OnInit {
clearFilters(): void { clearFilters(): void {
this.filterPageName = ''; this.filterPageName = '';
this.filterAction = ''; this.filterAction = '';
this.filterUserId = ''; this.filterUser = '';
this.filterSearch = ''; this.filterSearch = '';
this.dateFrom = ''; this.dateFrom = '';
this.dateTo = ''; this.dateTo = '';
@ -221,7 +221,7 @@ export class Historico implements OnInit {
pageSize: this.pageSize, pageSize: this.pageSize,
pageName: this.filterPageName || undefined, pageName: this.filterPageName || undefined,
action: this.filterAction || undefined, action: this.filterAction || undefined,
userId: this.filterUserId?.trim() || undefined, user: this.filterUser?.trim() || undefined,
search: this.filterSearch?.trim() || undefined, search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined, dateTo: this.toIsoDate(this.dateTo, true) || undefined,

View File

@ -21,7 +21,7 @@
type="email" type="email"
id="email" id="email"
formControlName="username" formControlName="username"
placeholder="admin@empresa.com" placeholder="usuario@empresa.com"
[class.error]="hasError('username')" [class.error]="hasError('username')"
> >
<div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div> <div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div>

View File

@ -30,7 +30,7 @@
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
<input <input
type="text" type="text"
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..." placeholder="Pesquisar..."
[(ngModel)]="search" [(ngModel)]="search"
(ngModelChange)="clearSelection()" (ngModelChange)="clearSelection()"
/> />
@ -115,8 +115,8 @@
class="list-item" class="list-item"
*ngFor="let n of filteredNotifications" *ngFor="let n of filteredNotifications"
[class.is-read]="n.lida" [class.is-read]="n.lida"
[class.is-danger]="getNotificationTipo(n) === 'Vencido'" [class.is-danger]="isVencido(n)"
[class.is-warning]="getNotificationTipo(n) === 'AVencer'" [class.is-warning]="isAVencer(n)"
> >
<div class="status-strip"></div> <div class="status-strip"></div>
@ -126,7 +126,12 @@
</label> </label>
<div class="item-icon"> <div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i> <i
class="bi"
[class.bi-x-circle-fill]="isVencido(n)"
[class.bi-clock-fill]="isAVencer(n)"
[class.bi-check2-circle]="isAutoRenew(n)">
</i>
</div> </div>
<div class="item-content"> <div class="item-content">
@ -156,8 +161,8 @@
<span class="meta-value">{{ n.planoContrato || '-' }}</span> <span class="meta-value">{{ n.planoContrato || '-' }}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'"> <span class="badge-tag" [class.danger]="isVencido(n)" [class.warn]="isAVencer(n)" [class.info]="isAutoRenew(n)">
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }} {{ getStatusLabel(n) }}
</span> </span>
</div> </div>
</div> </div>
@ -173,6 +178,27 @@
<i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i> <i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i>
<span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span> <span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span>
</button> </button>
<button
type="button"
class="btn-action ghost"
*ngIf="n.vigenciaLineId || n.linha"
title="Abrir na página de vigência"
(click)="goToVigencia(n)"
>
<i class="bi bi-box-arrow-up-right"></i>
<span class="d-none d-md-inline">Abrir vigência</span>
</button>
<button
type="button"
class="btn-action renew"
*ngIf="isAVencer(n)"
title="Programar renovação automática por mais 2 anos"
(click)="renewFromNotification(n)"
[disabled]="isRenewing(n)"
>
<i class="bi bi-arrow-repeat"></i>
<span class="d-none d-md-inline">{{ isRenewing(n) ? 'Aguarde...' : 'Renovar +2' }}</span>
</button>
</div> </div>
</div> </div>

View File

@ -260,6 +260,7 @@ $border: #e5e7eb;
.bi-x-circle-fill { color: $danger; } .bi-x-circle-fill { color: $danger; }
.bi-clock-fill { color: $warning; } .bi-clock-fill { color: $warning; }
.bi-check2-circle { color: $primary; }
} }
.item-content { flex: 1; min-width: 0; } .item-content { flex: 1; min-width: 0; }
@ -290,9 +291,9 @@ $border: #e5e7eb;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
align-items: flex-end; align-items: flex-start;
min-width: 170px; min-width: 170px;
text-align: right; text-align: left;
} }
.date-pill { .date-pill {
@ -323,17 +324,22 @@ $border: #e5e7eb;
&.danger { background: rgba($danger, 0.1); color: $danger; } &.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); } &.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
&.info { background: rgba($primary, 0.12); color: $primary; }
} }
.item-actions { .item-actions {
margin-left: 12px; align-self: center; margin-left: 12px;
align-self: center;
display: flex;
flex-direction: column;
gap: 6px;
} }
.btn-action { .btn-action {
background: white; border: 1px solid $border; background: white; border: 1px solid $border;
padding: 8px 12px; border-radius: 8px; padding: 6px 10px; border-radius: 7px;
cursor: pointer; 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; display: flex; align-items: center; gap: 6px;
transition: all 0.2s; transition: all 0.2s;
@ -344,6 +350,18 @@ $border: #e5e7eb;
@media(min-width: 768px) { opacity: 0.6; } @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) RESPONSIVIDADE MOBILE (Central de Notificações)
========================================================================== */ ========================================================================== */
@ -634,14 +652,17 @@ $border: #e5e7eb;
grid-column: 1 / -1; grid-column: 1 / -1;
margin: 2px 0 0 0; margin: 2px 0 0 0;
width: 100%; width: 100%;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
} }
.btn-action { .btn-action {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
padding: 8px 10px; padding: 6px 8px;
border-radius: 10px; border-radius: 9px;
font-size: 12px; font-size: 11px;
gap: 6px; gap: 6px;
} }
@ -727,7 +748,7 @@ $border: #e5e7eb;
} }
.btn-action { .btn-action {
font-size: 11px; font-size: 10px;
padding: 7px 8px; padding: 6px 7px;
} }
} }

View File

@ -1,9 +1,11 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { NotificationsService, NotificationDto } from '../../services/notifications.service'; import { NotificationsService, NotificationDto } from '../../services/notifications.service';
import { VigenciaService } from '../../services/vigencia.service';
@Component({ @Component({
selector: 'app-notificacoes', selector: 'app-notificacoes',
@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy {
bulkUnreadLoading = false; bulkUnreadLoading = false;
exportLoading = false; exportLoading = false;
selectedIds = new Set<string>(); selectedIds = new Set<string>();
renewingKey: string | null = null;
private readonly subs = new Subscription(); private readonly subs = new Subscription();
constructor(private notificationsService: NotificationsService) {} constructor(
private notificationsService: NotificationsService,
private router: Router,
private vigenciaService: VigenciaService
) {}
ngOnInit(): void { ngOnInit(): void {
this.loadNotifications(); this.loadNotifications();
@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR'); 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 reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference); const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo; if (!parsed) return notification.tipo;
@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed < today ? 'Vencido' : 'AVencer'; 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() { private loadNotifications() {
this.loading = true; this.loading = true;
this.error = false; this.error = false;
@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy {
private shouldMarkRead(n: NotificationDto): boolean { private shouldMarkRead(n: NotificationDto): boolean {
if (this.filter === 'todas') return true; if (this.filter === 'todas') return true;
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer'; if (this.filter === 'aVencer') return this.isAVencer(n);
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido'; if (this.filter === 'vencidas') return this.isVencido(n);
return false; return false;
} }
@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy {
return this.notifications.filter(n => n.lida); return this.notifications.filter(n => n.lida);
} }
if (this.filter === 'vencidas') { 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') { 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). // "todas" aqui representa o inbox: pendentes (não lidas).
return this.notifications.filter(n => !n.lida); return this.notifications.filter(n => !n.lida);

View File

@ -113,7 +113,7 @@
type="button" type="button"
title="Excluir" title="Excluir"
aria-label="Excluir" aria-label="Excluir"
*ngIf="isAdmin" *ngIf="isSysAdmin"
(click)="remove.emit(row)"> (click)="remove.emit(row)">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>

View File

@ -26,7 +26,7 @@ export class ParcelamentosTableComponent {
@Input() items: ParcelamentoViewItem[] = []; @Input() items: ParcelamentoViewItem[] = [];
@Input() loading = false; @Input() loading = false;
@Input() errorMessage = ''; @Input() errorMessage = '';
@Input() isAdmin = false; @Input() isSysAdmin = false;
@Input() segment: ParcelamentoSegment = 'todos'; @Input() segment: ParcelamentoSegment = 'todos';
@Input() segmentCounts: Record<ParcelamentoSegment, number> = { @Input() segmentCounts: Record<ParcelamentoSegment, number> = {

View File

@ -65,7 +65,7 @@
[total]="total" [total]="total"
[pageSize]="pageSize" [pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions" [pageSizeOptions]="pageSizeOptions"
[isAdmin]="isAdmin" [isSysAdmin]="isSysAdmin"
(segmentChange)="setSegment($event)" (segmentChange)="setSegment($event)"
(detail)="openDetails($event)" (detail)="openDetails($event)"
(edit)="openEdit($event)" (edit)="openEdit($event)"

View File

@ -87,7 +87,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
kpiCards: ParcelamentoKpi[] = []; kpiCards: ParcelamentoKpi[] = [];
activeChips: FilterChip[] = []; activeChips: FilterChip[] = [];
isAdmin = false; isSysAdmin = false;
detailOpen = false; detailOpen = false;
detailLoading = false; detailLoading = false;
@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
private syncPermissions(): void { private syncPermissions(): void {
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
} }
get totalPages(): number { get totalPages(): number {
@ -440,7 +440,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
} }
openDelete(item: ParcelamentoViewItem): void { openDelete(item: ParcelamentoViewItem): void {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.deleteTarget = item; this.deleteTarget = item;
this.deleteError = ''; this.deleteError = '';
this.deleteOpen = true; this.deleteOpen = true;

View File

@ -7,7 +7,7 @@
<div class="card-shell"> <div class="card-shell">
<header class="card-header"> <header class="card-header">
<div class="title-badge"> <div class="title-badge">
<i class="bi bi-shield-lock-fill"></i> SYSTEM ADMIN <i class="bi bi-shield-lock-fill"></i> SYSADMIN
</div> </div>
<h1>Fornecer Usuário para Cliente</h1> <h1>Fornecer Usuário para Cliente</h1>
<p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p> <p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p>

View File

@ -11,10 +11,10 @@ import {
} from '@angular/forms'; } from '@angular/forms';
import { import {
SystemAdminService, SysadminService,
SystemTenantDto, SystemTenantDto,
CreateSystemTenantUserResponse, CreateSystemTenantUserResponse,
} from '../../services/system-admin.service'; } from '../../services/sysadmin.service';
type RoleOption = { type RoleOption = {
value: string; value: string;
@ -50,7 +50,7 @@ export class SystemProvisionUserPage implements OnInit {
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private systemAdminService: SystemAdminService private sysadminService: SysadminService
) { ) {
this.provisionForm = this.fb.group( this.provisionForm = this.fb.group(
{ {
@ -75,7 +75,7 @@ export class SystemProvisionUserPage implements OnInit {
this.tenantsLoading = true; this.tenantsLoading = true;
this.tenantsError = ''; this.tenantsError = '';
this.systemAdminService this.sysadminService
.listTenants({ source: this.sourceType, active: true }) .listTenants({ source: this.sourceType, active: true })
.subscribe({ .subscribe({
next: (tenants) => { next: (tenants) => {
@ -133,7 +133,7 @@ export class SystemProvisionUserPage implements OnInit {
this.submitting = true; this.submitting = true;
this.setFormDisabled(true); this.setFormDisabled(true);
this.systemAdminService this.sysadminService
.createTenantUser(tenantId, { .createTenantUser(tenantId, {
name: nameRaw, name: nameRaw,
email, email,

View File

@ -24,7 +24,7 @@
<small class="subtitle">Controle de contratos e fidelização</small> <small class="subtitle">Controle de contratos e fidelização</small>
</div> </div>
<div class="header-actions d-flex gap-2 justify-content-end"> <div class="header-actions d-flex gap-2 justify-content-end">
<button *ngIf="isAdmin" class="btn btn-brand btn-sm" (click)="openCreate()"> <button *ngIf="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência <i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button> </button>
</div> </div>
@ -119,7 +119,7 @@
<th>LINHA</th> <th>LINHA</th>
<th>CONTA</th> <th>CONTA</th>
<th>USUÁRIO</th> <th>USUÁRIO</th>
<th>PLANO</th> <th class="plano-col">PLANO</th>
<th>EFETIVAÇÃO</th> <th>EFETIVAÇÃO</th>
<th>VENCIMENTO</th> <th>VENCIMENTO</th>
<th class="text-end">TOTAL</th> <th class="text-end">TOTAL</th>
@ -132,7 +132,7 @@
<td class="fw-black text-blue">{{ row.linha }}</td> <td class="fw-black text-blue">{{ row.linha }}</td>
<td class="text-dark small">{{ row.conta || '-' }}</td> <td class="text-dark small">{{ row.conta || '-' }}</td>
<td class="text-muted small">{{ row.usuario || '-' }}</td> <td class="text-muted small">{{ row.usuario || '-' }}</td>
<td class="text-muted small td-clip" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td> <td class="text-muted small td-clip plano-col" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
<td class="text-muted small fw-bold"> <td class="text-muted small fw-bold">
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }} {{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
@ -146,11 +146,19 @@
{{ (row.total || 0) | currency:'BRL' }} {{ (row.total || 0) | currency:'BRL' }}
</td> </td>
<td> <td class="actions-col">
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<span class="renew-chip" *ngIf="row.autoRenewYears">{{ getRenewalBadge(row) }}</span>
<button
*ngIf="isAVencer(row.dtTerminoFidelizacao)"
class="btn btn-primary btn-xs"
(click)="scheduleAutoRenew(row)"
title="Renovar por mais 2 anos">
Renovar +2 anos
</button>
<button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button> <button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -236,6 +244,12 @@
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }} {{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span> </span>
</div> </div>
<div class="info-item">
<span class="lbl">Renovação</span>
<span class="val">
{{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }}
</span>
</div>
<div class="info-item"> <div class="info-item">
<span class="lbl">Valor Total</span> <span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span> <span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>

View File

@ -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); } .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 */ /* 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 { .table-modern {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%;
border-collapse: separate;
border-spacing: 0;
thead th { thead th {
position: sticky; top: 0; z-index: 10; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); 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; border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px;
@ -301,9 +304,26 @@
.fw-black { font-weight: 950; } .fw-black { font-weight: 950; }
.text-brand { color: var(--brand) !important; } .text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !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 { .action-group {
display: flex; display: flex;
@ -312,6 +332,27 @@
gap: 6px; gap: 6px;
flex-wrap: nowrap; flex-wrap: nowrap;
white-space: 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 { .btn-icon {
@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
} }
.actions-col { .actions-col {
min-width: 120px; min-width: 210px;
width: 210px;
} }
.action-group { .action-group {
@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
} }
.actions-col { .actions-col {
min-width: 106px; min-width: 190px;
width: 190px;
} }
.action-group { .action-group {

View File

@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http'; 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 { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
@ -98,30 +100,34 @@ export class VigenciaComponent implements OnInit, OnDestroy {
clientsFromGeral: string[] = []; clientsFromGeral: string[] = [];
planOptions: string[] = []; planOptions: string[] = [];
isAdmin = false; isSysAdmin = false;
toastOpen = false; toastOpen = false;
toastMessage = ''; toastMessage = '';
toastType: ToastType = 'success'; toastType: ToastType = 'success';
private toastTimer: any = null; private toastTimer: any = null;
private searchTimer: any = null; private searchTimer: any = null;
private readonly subs = new Subscription();
constructor( constructor(
private vigenciaService: VigenciaService, private vigenciaService: VigenciaService,
private authService: AuthService, private authService: AuthService,
private linesService: LinesService, private linesService: LinesService,
private planAutoFill: PlanAutoFillService private planAutoFill: PlanAutoFillService,
private route: ActivatedRoute
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.isAdmin = this.authService.hasRole('sysadmin'); this.isSysAdmin = this.authService.hasRole('sysadmin');
this.loadClients(); this.loadClients();
this.loadPlanRules(); this.loadPlanRules();
this.fetch(1); this.fetch(1);
this.bindOpenFromNotificationQuery();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer); if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer); if (this.toastTimer) clearTimeout(this.toastTimer);
this.subs.unsubscribe();
} }
setView(mode: ViewMode): void { setView(mode: ViewMode): void {
@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return this.startOfDay(d) >= this.startOfDay(new Date()); 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 { public parseAnyDate(value: any): Date | null {
if (!value) return null; if (!value) return null;
const d = new Date(value); const d = new Date(value);
@ -273,11 +294,27 @@ export class VigenciaComponent implements OnInit, OnDestroy {
if (this.searchTimer) clearTimeout(this.searchTimer); if (this.searchTimer) clearTimeout(this.searchTimer);
this.fetch(1); 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; } openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; } closeDetails() { this.detailsOpen = false; }
openEdit(r: VigenciaRow) { openEdit(r: VigenciaRow) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.editingId = r.id; this.editingId = r.id;
this.editModel = { ...r }; this.editModel = { ...r };
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico); this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
@ -328,7 +365,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
// CREATE // CREATE
// ========================== // ==========================
openCreate() { openCreate() {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.resetCreateModel(); this.resetCreateModel();
this.createOpen = true; this.createOpen = true;
this.preloadGeralClients(); this.preloadGeralClients();
@ -507,7 +544,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
} }
openDelete(r: VigenciaRow) { openDelete(r: VigenciaRow) {
if (!this.isAdmin) return; if (!this.isSysAdmin) return;
this.deleteTarget = r; this.deleteTarget = r;
this.deleteOpen = true; this.deleteOpen = true;
} }
@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n; 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.isSysAdmin && 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.isSysAdmin && 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) { handleError(err: HttpErrorResponse, msg: string) {
this.loading = false; this.loading = false;
this.expandedLoading = false; this.expandedLoading = false;

View File

@ -42,7 +42,7 @@ export interface HistoricoQuery {
pageName?: string; pageName?: string;
action?: AuditAction | string; action?: AuditAction | string;
entity?: string; entity?: string;
userId?: string; user?: string;
search?: string; search?: string;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
@ -64,7 +64,7 @@ export class HistoricoService {
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName); if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
if (params.action) httpParams = httpParams.set('action', params.action); if (params.action) httpParams = httpParams.set('action', params.action);
if (params.entity) httpParams = httpParams.set('entity', params.entity); 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.search) httpParams = httpParams.set('search', params.search);
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);

View File

@ -4,7 +4,7 @@ import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
export type NotificationTipo = 'AVencer' | 'Vencido'; export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
export type NotificationDto = { export type NotificationDto = {
id: string; id: string;

View File

@ -29,7 +29,7 @@ export type CreateSystemTenantUserResponse = {
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SystemAdminService { export class SysadminService {
private readonly baseApi: string; private readonly baseApi: string;
constructor(private http: HttpClient) { constructor(private http: HttpClient) {

View File

@ -22,6 +22,10 @@ export interface VigenciaRow {
planoContrato: string | null; planoContrato: string | null;
dtEfetivacaoServico: string | null; dtEfetivacaoServico: string | null;
dtTerminoFidelizacao: string | null; dtTerminoFidelizacao: string | null;
autoRenewYears?: number | null;
autoRenewReferenceEndDate?: string | null;
autoRenewConfiguredAt?: string | null;
lastAutoRenewedAt?: string | null;
total: number | null; total: number | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@ -40,6 +44,9 @@ export interface UpdateVigenciaRequest {
} }
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {} export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
export interface ConfigureVigenciaRenewalRequest {
years: 2;
}
export interface VigenciaClientGroup { export interface VigenciaClientGroup {
cliente: string; cliente: string;
@ -118,4 +125,8 @@ export class VigenciaService {
remove(id: string): Observable<void> { remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`); return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`);
} }
configureAutoRenew(id: string, payload: ConfigureVigenciaRenewalRequest): Observable<void> {
return this.http.post<void>(`${this.baseApi}/lines/vigencia/${id}/renew`, payload);
}
} }