Merge pull request #30 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
This commit is contained in:
commit
875345ea89
15
README.md
15
README.md
|
|
@ -57,3 +57,18 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
|
|||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
||||
## Planilha Modelo (GERAL) - Lote de Linhas
|
||||
|
||||
- Local do botão:
|
||||
- Página `Geral`
|
||||
- Modal `Adicionar linha` ou `Novo cliente`
|
||||
- Modo `Lote de Linhas`
|
||||
- Bloco de importação por Excel
|
||||
- Botão: `Baixar Modelo (GERAL)`
|
||||
|
||||
- Endpoint chamado pelo front-end:
|
||||
- `GET /api/templates/planilha-geral`
|
||||
|
||||
- Arquivo baixado:
|
||||
- `MODELO_GERAL_LINEGESTAO.xlsx`
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/node": "^20.17.19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"jasmine-core": "~5.9.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
|
|
@ -3732,13 +3733,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/beasties": {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/node": "^20.17.19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"jasmine-core": "~5.9.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { Mureg } from './pages/mureg/mureg';
|
|||
import { Faturamento } from './pages/faturamento/faturamento';
|
||||
|
||||
import { authGuard } from './guards/auth.guard';
|
||||
import { adminGuard } from './guards/admin.guard';
|
||||
import { systemAdminGuard } from './guards/system-admin.guard';
|
||||
import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard';
|
||||
import { sysadminOnlyGuard } from './guards/sysadmin-only.guard';
|
||||
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
||||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||
|
|
@ -29,20 +29,20 @@ export const routes: Routes = [
|
|||
|
||||
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
|
||||
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], title: 'Faturamento' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Faturamento' },
|
||||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
|
||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
|
||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
|
||||
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, adminGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
|
||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||
{
|
||||
path: 'system/fornecer-usuario',
|
||||
component: SystemProvisionUserPage,
|
||||
canActivate: [authGuard, systemAdminGuard],
|
||||
canActivate: [authGuard, sysadminOnlyGuard],
|
||||
title: 'Fornecer Usuário',
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -185,13 +185,13 @@
|
|||
<i class="bi bi-person-circle"></i> Perfil
|
||||
</button>
|
||||
<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
|
||||
</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
|
||||
</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)
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
|
|
@ -537,7 +537,7 @@
|
|||
<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>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,9 +31,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
manageUsersOpen = false;
|
||||
isLoggedHeader = false;
|
||||
isHome = false;
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
canViewAll = false;
|
||||
isSystemAdmin = false;
|
||||
notifications: NotificationDto[] = [];
|
||||
notificationsLoading = false;
|
||||
notificationsError = false;
|
||||
|
|
@ -203,16 +202,14 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
|
||||
private syncPermissions() {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
this.isAdmin = false;
|
||||
this.isSysAdmin = false;
|
||||
this.canViewAll = false;
|
||||
this.isSystemAdmin = false;
|
||||
return;
|
||||
}
|
||||
const isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
const isGestor = this.authService.hasRole('gestor');
|
||||
this.isAdmin = isSysAdmin;
|
||||
this.isSysAdmin = isSysAdmin;
|
||||
this.canViewAll = isSysAdmin || isGestor;
|
||||
this.isSystemAdmin = this.authService.hasRole('sysadmin');
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
|
|
@ -238,13 +235,13 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
goToSystemProvisionUser() {
|
||||
if (!this.isSystemAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.closeOptions();
|
||||
this.router.navigate(['/system/fornecer-usuario']);
|
||||
}
|
||||
|
||||
openCreateUserModal() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.createUserOpen = true;
|
||||
this.closeOptions();
|
||||
this.resetCreateUserState();
|
||||
|
|
@ -256,7 +253,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
openManageUsersModal() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.manageUsersOpen = true;
|
||||
this.closeOptions();
|
||||
this.resetManageUsersState();
|
||||
|
|
@ -359,7 +356,10 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
getVigenciaLabel(notification: NotificationDto): string {
|
||||
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em';
|
||||
const tipo = this.getNotificationTipo(notification);
|
||||
if (tipo === 'Vencido') return 'Venceu em';
|
||||
if (tipo === 'AVencer') return 'Vence em';
|
||||
return 'Atualizado em';
|
||||
}
|
||||
|
||||
getVigenciaDate(notification: NotificationDto): string {
|
||||
|
|
@ -373,7 +373,11 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
return parsed.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
|
||||
getNotificationTipo(notification: NotificationDto): string {
|
||||
if (notification.tipo === 'RenovacaoAutomatica') {
|
||||
return 'RenovacaoAutomatica';
|
||||
}
|
||||
|
||||
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||
const parsed = this.parseDateOnly(reference);
|
||||
if (!parsed) return notification.tipo;
|
||||
|
|
@ -445,9 +449,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.authService.logout();
|
||||
this.optionsOpen = false;
|
||||
this.notificationsOpen = false;
|
||||
this.isAdmin = false;
|
||||
this.isSysAdmin = false;
|
||||
this.canViewAll = false;
|
||||
this.isSystemAdmin = false;
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
|
|
@ -609,7 +612,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.createUserForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!this.isAdmin) {
|
||||
if (!this.isSysAdmin) {
|
||||
this.createUserForbidden = true;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { isPlatformBrowser } from '@angular/common';
|
|||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const systemAdminGuard: CanActivateFn = () => {
|
||||
export const sysadminOnlyGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
const authService = inject(AuthService);
|
||||
|
|
@ -18,8 +18,8 @@ export const systemAdminGuard: CanActivateFn = () => {
|
|||
return router.parseUrl('/login');
|
||||
}
|
||||
|
||||
const isSystemAdmin = authService.hasRole('sysadmin');
|
||||
if (!isSystemAdmin) {
|
||||
const isSysAdmin = authService.hasRole('sysadmin');
|
||||
if (!isSysAdmin) {
|
||||
return router.parseUrl('/dashboard');
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ import { CanActivateFn, Router } from '@angular/router';
|
|||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const adminGuard: CanActivateFn = () => {
|
||||
export const sysadminOrGestorGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
const authService = inject(AuthService);
|
||||
|
|
@ -36,14 +36,14 @@
|
|||
|
||||
<div class="header-actions d-flex gap-2 justify-content-end">
|
||||
<button
|
||||
*ngIf="isAdmin && activeTab === 'chips'"
|
||||
*ngIf="isSysAdmin && activeTab === 'chips'"
|
||||
class="btn btn-brand btn-sm"
|
||||
(click)="openChipCreate()"
|
||||
>
|
||||
<i class="bi bi-plus-circle me-1"></i> Novo Chip
|
||||
</button>
|
||||
<button
|
||||
*ngIf="isAdmin && activeTab === 'controle'"
|
||||
*ngIf="isSysAdmin && activeTab === 'controle'"
|
||||
class="btn btn-brand btn-sm"
|
||||
(click)="openControleCreate()"
|
||||
>
|
||||
|
|
@ -197,10 +197,10 @@
|
|||
<button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes">
|
||||
<i class="bi bi-eye"></i>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -295,10 +295,10 @@
|
|||
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
|
||||
<i class="bi bi-eye"></i>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -339,10 +339,10 @@
|
|||
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
|
||||
<i class="bi bi-eye"></i>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
controleDeleteOpen = false;
|
||||
controleDeleteTarget: ControleRecebidoListDto | null = null;
|
||||
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
|
||||
constructor(
|
||||
@Inject(PLATFORM_ID) private platformId: object,
|
||||
|
|
@ -129,7 +129,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
|
||||
ngOnInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.fetchChips();
|
||||
this.fetchControle();
|
||||
}
|
||||
|
|
@ -236,7 +236,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openChipCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.chipCreateModel = {
|
||||
id: '',
|
||||
item: null,
|
||||
|
|
@ -278,7 +278,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openChipEdit(row: ChipVirgemListDto) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.service.getChipVirgemById(row.id).subscribe({
|
||||
next: (data) => {
|
||||
this.chipEditingId = data.id;
|
||||
|
|
@ -319,7 +319,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openChipDelete(row: ChipVirgemListDto) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.chipDeleteTarget = row;
|
||||
this.chipDeleteOpen = true;
|
||||
}
|
||||
|
|
@ -498,7 +498,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openControleCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.controleCreateModel = {
|
||||
id: '',
|
||||
ano: new Date().getFullYear(),
|
||||
|
|
@ -603,7 +603,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openControleEdit(row: ControleRecebidoListDto) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.service.getControleRecebidoById(row.id).subscribe({
|
||||
next: (data) => {
|
||||
this.controleEditingId = data.id;
|
||||
|
|
@ -659,7 +659,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openControleDelete(row: ControleRecebidoListDto) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.controleDeleteTarget = row;
|
||||
this.controleDeleteOpen = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -153,8 +153,8 @@
|
|||
<td>
|
||||
<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 *ngIf="isAdmin" 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 primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export class DadosUsuarios implements OnInit {
|
|||
createClientsLoading = false;
|
||||
createLinesLoading = false;
|
||||
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: 'success' | 'danger' = 'success';
|
||||
|
|
@ -117,7 +117,7 @@ export class DadosUsuarios implements OnInit {
|
|||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.fetch(1);
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +283,7 @@ export class DadosUsuarios implements OnInit {
|
|||
closeDetails() { this.detailsOpen = false; }
|
||||
|
||||
openEdit(row: UserDataRow) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.service.getById(row.id).subscribe({
|
||||
next: (fullData: UserDataRow) => {
|
||||
this.editingId = fullData.id;
|
||||
|
|
@ -366,7 +366,7 @@ export class DadosUsuarios implements OnInit {
|
|||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.preloadGeralClients();
|
||||
|
|
@ -532,7 +532,7 @@ export class DadosUsuarios implements OnInit {
|
|||
}
|
||||
|
||||
openDelete(row: UserDataRow) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.deleteTarget = row;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,8 +286,8 @@
|
|||
<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 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="isAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
BillingUpdateRequest
|
||||
} from '../../services/billing';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { LinesService } from '../../services/lines.service';
|
||||
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
||||
|
||||
interface BillingClientGroup {
|
||||
|
|
@ -51,6 +52,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
constructor(
|
||||
@Inject(PLATFORM_ID) private platformId: object,
|
||||
private billing: BillingService,
|
||||
private linesService: LinesService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
|
@ -103,9 +105,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
deleteOpen = false;
|
||||
deleteTarget: BillingItem | null = null;
|
||||
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
|
||||
private searchTimer: any = null;
|
||||
private searchResolvedClients: string[] = [];
|
||||
private searchResolveVersion = 0;
|
||||
|
||||
// cache do ALL
|
||||
private allCache: BillingItem[] = [];
|
||||
|
|
@ -160,7 +164,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
this.initAnimations();
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
|
||||
setTimeout(() => {
|
||||
this.refreshData(true);
|
||||
|
|
@ -351,22 +355,59 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
onSearch() {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.searchTimer = setTimeout(async () => {
|
||||
this.page = 1;
|
||||
this.expandedGroup = null;
|
||||
this.groupRows = [];
|
||||
await this.resolveSearchClientsByLineOrChip();
|
||||
this.refreshData();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchTerm = '';
|
||||
this.searchResolvedClients = [];
|
||||
this.page = 1;
|
||||
this.expandedGroup = null;
|
||||
this.groupRows = [];
|
||||
this.refreshData();
|
||||
}
|
||||
|
||||
private isSpecificLineOrChipSearch(term: string): boolean {
|
||||
const digits = (term ?? '').replace(/\D/g, '');
|
||||
return digits.length >= 8;
|
||||
}
|
||||
|
||||
private async resolveSearchClientsByLineOrChip(): Promise<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
|
||||
// --------------------------
|
||||
|
|
@ -513,8 +554,12 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
const term = this.normalizeText(this.searchTerm);
|
||||
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x)));
|
||||
if (term) {
|
||||
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
|
||||
arr = arr.filter((r) =>
|
||||
this.buildGlobalSearchBlob(r).includes(term) ||
|
||||
(resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente)))
|
||||
);
|
||||
}
|
||||
|
||||
// KPIs
|
||||
|
|
@ -669,7 +714,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onEditar(r: BillingItem) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editOpen = true;
|
||||
|
|
@ -684,7 +729,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onDelete(r: BillingItem) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
this.cdr.detectChanges();
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@
|
|||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
*ngIf="isAdmin"
|
||||
*ngIf="isSysAdmin"
|
||||
(click)="onImportExcel()"
|
||||
[disabled]="loading">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
|
||||
</button>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
|
|
@ -293,21 +294,49 @@
|
|||
<div class="group-header" (click)="toggleGroup(group.cliente)">
|
||||
<div class="group-info">
|
||||
<h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6>
|
||||
<div class="group-tags">
|
||||
<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 blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} bloqueadas</span>
|
||||
<div class="group-tags">
|
||||
<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 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 class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<small class="text-muted fw-bold">Gerenciar Grupo</small>
|
||||
<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 class="d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||
<ng-container *ngIf="hasGroupLineSelectionTools">
|
||||
<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>
|
||||
|
||||
<!-- ✅ 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">
|
||||
<thead>
|
||||
<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>LINHA</th>
|
||||
<th>USUÁRIO</th>
|
||||
|
|
@ -330,20 +369,39 @@
|
|||
|
||||
<tbody>
|
||||
<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="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>
|
||||
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
|
||||
<td class="text-muted small fw-bold" *ngIf="!isClientRestricted">{{ r.contrato }}</td>
|
||||
|
||||
<td>
|
||||
<div class="action-group justify-content-center">
|
||||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<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 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>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -430,7 +488,7 @@
|
|||
</div>
|
||||
</th>
|
||||
|
||||
<th class="text-center">AÇÕES</th>
|
||||
<th class="text-center actions-col-main">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
|
@ -462,7 +520,7 @@
|
|||
<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 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>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -507,14 +565,14 @@
|
|||
<!-- Backdrop -->
|
||||
<div
|
||||
class="modal-backdrop-custom"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||
(click)="closeAllModals()">
|
||||
</div>
|
||||
|
||||
<!-- Overlay (captura clique fora) -->
|
||||
<div
|
||||
class="modal-custom"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
|
||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||
(click)="closeAllModals()"
|
||||
>
|
||||
<!-- CREATE MODAL -->
|
||||
|
|
@ -914,233 +972,122 @@
|
|||
e podem ser diferentes entre linhas.
|
||||
</div>
|
||||
|
||||
<div class="batch-mass-input-box">
|
||||
<div class="batch-mass-input-box mb-3">
|
||||
<div class="batch-mass-input-head">
|
||||
<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">
|
||||
Cole ou digite várias linhas em sequência. Formato padrão:
|
||||
<code>linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao</code>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-mass-controls">
|
||||
<label class="small fw-bold text-muted mb-0">Separador</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="batchMassSeparatorMode" (ngModelChange)="onBatchMassInputChange()">
|
||||
<option value="AUTO">Automático</option>
|
||||
<option value="SEMICOLON">;</option>
|
||||
<option value="TAB">TAB</option>
|
||||
<option value="PIPE">|</option>
|
||||
</select>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap justify-content-end">
|
||||
<button type="button" class="btn btn-sm btn-glass" (click)="onDownloadBatchExcelTemplate()" [disabled]="createSaving || batchExcelTemplateDownloading || batchExcelPreviewLoading">
|
||||
<span *ngIf="!batchExcelTemplateDownloading"><i class="bi bi-download me-1"></i> Baixar Modelo (GERAL)</span>
|
||||
<span *ngIf="batchExcelTemplateDownloading"><span class="spinner-border spinner-border-sm me-2"></span> Baixando...</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-brand" (click)="onImportBatchExcel()" [disabled]="createSaving || batchExcelPreviewLoading">
|
||||
<span *ngIf="!batchExcelPreviewLoading"><i class="bi bi-paperclip me-1"></i> Anexar Excel</span>
|
||||
<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>
|
||||
|
||||
<details class="batch-mass-guide" open>
|
||||
<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" *ngIf="batchExcelPreview as excelPreview">
|
||||
<div class="batch-mass-preview-pills">
|
||||
<span class="summary-pill total">Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }}</span>
|
||||
<span class="summary-pill ok">Válidas: {{ batchMassPreview?.validRows || 0 }}</span>
|
||||
<span class="summary-pill warn" *ngIf="(batchMassPreview?.invalidRows || 0) > 0">
|
||||
Inválidas: {{ batchMassPreview?.invalidRows || 0 }}
|
||||
</span>
|
||||
<span class="summary-pill dup" *ngIf="(batchMassPreview?.duplicateRows || 0) > 0">
|
||||
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>
|
||||
<span class="summary-pill total">Aba: {{ excelPreview.sheetName || 'GERAL' }}</span>
|
||||
<span class="summary-pill total">Linhas lidas: {{ excelPreview.totalRows || 0 }}</span>
|
||||
<span class="summary-pill ok">Válidas: {{ excelPreview.validRows || 0 }}</span>
|
||||
<span class="summary-pill warn" *ngIf="(excelPreview.invalidRows || 0) > 0">Inválidas: {{ excelPreview.invalidRows || 0 }}</span>
|
||||
<span class="summary-pill dup" *ngIf="(excelPreview.duplicateRows || 0) > 0">Duplicadas: {{ excelPreview.duplicateRows || 0 }}</span>
|
||||
<span class="summary-pill" *ngIf="excelPreview.nextItemStart > 0">Próx. ITÉM (sistema): {{ excelPreview.nextItemStart }}</span>
|
||||
</div>
|
||||
|
||||
<div class="batch-mass-preview-errors" *ngIf="(batchMassPreview?.parseErrors?.length || 0) > 0">
|
||||
<div class="fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Erros de parsing</div>
|
||||
<ul>
|
||||
<li *ngFor="let err of batchMassPreview?.parseErrors">{{ err }}</li>
|
||||
<div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerErrors?.length || 0) > 0">
|
||||
<strong>Erros de cabeçalho/estrutura</strong>
|
||||
<ul class="mb-0 mt-1">
|
||||
<li *ngFor="let err of excelPreview.headerErrors">
|
||||
<strong *ngIf="err.column">{{ err.column }}:</strong> {{ err.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Linha origem</th>
|
||||
<th>Planilha</th>
|
||||
<th>ITÉM (origem)</th>
|
||||
<th>ITÉM (sistema)</th>
|
||||
<th>Linha</th>
|
||||
<th>Chip</th>
|
||||
<th>Plano</th>
|
||||
<th>Status</th>
|
||||
<th>Conta</th>
|
||||
<th>Status</th>
|
||||
<th>Validação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of batchMassPreviewRowsPreview">
|
||||
<td>#{{ row.line }}</td>
|
||||
<td>{{ row.data['linha'] || '-' }}</td>
|
||||
<td>{{ row.data['chip'] || '-' }}</td>
|
||||
<td>{{ row.data['planoContrato'] || '-' }}</td>
|
||||
<td>{{ row.data['status'] || '-' }}</td>
|
||||
<td>{{ row.data['conta'] || '-' }}</td>
|
||||
<td>
|
||||
<span class="batch-row-valid" *ngIf="row.errors.length === 0"><i class="bi bi-check-circle-fill"></i> OK</span>
|
||||
<div class="batch-row-errors-compact" *ngIf="row.errors.length > 0" [attr.title]="row.errors.join(' | ')">
|
||||
<div class="batch-row-error-main">{{ row.errors[0] }}</div>
|
||||
<div class="batch-row-more" *ngIf="row.errors.length > 1">+{{ row.errors.length - 1 }} pendência(s)</div>
|
||||
<tr *ngFor="let row of batchExcelPreviewRowsPreview">
|
||||
<td>#{{ row.sourceRowNumber }}</td>
|
||||
<td>{{ row.sourceItem ?? '-' }}</td>
|
||||
<td>{{ row.generatedItemPreview ?? '-' }}</td>
|
||||
<td>{{ row.data.linha || '-' }}</td>
|
||||
<td>{{ row.data.chip || '-' }}</td>
|
||||
<td>{{ row.data.conta || '-' }}</td>
|
||||
<td>{{ row.data.status || '-' }}</td>
|
||||
<td class="validation-cell">
|
||||
<div class="batch-row-valid" *ngIf="(row.errors.length || 0) === 0">
|
||||
<i class="bi bi-check-circle-fill"></i> OK
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="batch-mass-preview-foot" *ngIf="(batchMassPreview?.recognizedRows || 0) > 5">
|
||||
Mostrando 5 de {{ batchMassPreview?.recognizedRows }} linha(s) na prévia.
|
||||
<div class="batch-mass-preview-foot" *ngIf="(excelPreview.rows.length || 0) > (batchExcelPreviewRowsPreview.length || 0)">
|
||||
Mostrando {{ batchExcelPreviewRowsPreview.length }} de {{ excelPreview.rows.length || 0 }} linha(s) na prévia da planilha.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1167,8 +1114,8 @@
|
|||
</div>
|
||||
|
||||
<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
|
||||
sequência e carregá-las na grade.
|
||||
Nenhuma linha no lote ainda. Use a <strong>importação por planilha</strong> acima para pré-visualizar e
|
||||
carregar as linhas na grade.
|
||||
</div>
|
||||
|
||||
<div class="batch-editor-layout" *ngIf="createBatchCount > 0">
|
||||
|
|
@ -1279,7 +1226,7 @@
|
|||
|
||||
<div class="batch-selected-hint">
|
||||
<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
|
||||
cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
|
||||
</div>
|
||||
|
|
@ -1532,6 +1479,198 @@
|
|||
</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 -->
|
||||
<div
|
||||
*ngIf="detailOpen"
|
||||
|
|
|
|||
|
|
@ -336,6 +336,118 @@
|
|||
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); } }
|
||||
|
||||
/* Inner Table Destravada */
|
||||
|
|
@ -351,6 +463,22 @@
|
|||
.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); } }
|
||||
|
||||
/* 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 */
|
||||
.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); } }
|
||||
|
|
@ -378,6 +506,54 @@
|
|||
.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.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) === */
|
||||
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface LineRow {
|
|||
id: string;
|
||||
item: string;
|
||||
linha: string;
|
||||
chip?: string;
|
||||
cliente: string;
|
||||
usuario: string;
|
||||
status: string;
|
||||
|
|
@ -63,6 +64,7 @@ interface ApiLineList {
|
|||
id: string;
|
||||
item: number;
|
||||
linha: string | null;
|
||||
chip?: string | null;
|
||||
cliente: string | null;
|
||||
usuario: 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({
|
||||
standalone: true,
|
||||
|
|
@ -183,6 +247,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
|
||||
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('batchExcelInput') batchExcelInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
@ViewChild('editModal', { static: false }) editModal!: 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`;
|
||||
return `${apiBase}/lines`;
|
||||
})();
|
||||
private readonly templatesApiBase = (() => {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
return `${apiBase}/templates`;
|
||||
})();
|
||||
loading = false;
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
isGestor = false;
|
||||
isClientRestricted = false;
|
||||
|
||||
|
|
@ -259,9 +329,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
batchMassInputText = '';
|
||||
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
|
||||
batchMassPreview: BatchMassPreviewResult | null = null;
|
||||
batchExcelPreview: BatchExcelPreviewResultDto | null = null;
|
||||
batchExcelPreviewLoading = false;
|
||||
batchExcelTemplateDownloading = false;
|
||||
batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD';
|
||||
createBatchValidationByUid: Record<number, BatchLineValidation> = {};
|
||||
createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
|
||||
|
||||
reservaSelectedLineIds: string[] = [];
|
||||
reservaTransferOpen = false;
|
||||
reservaTransferSaving = false;
|
||||
moveToReservaOpen = false;
|
||||
moveToReservaSaving = false;
|
||||
reservaTransferClients: string[] = [];
|
||||
reservaTransferModel: { clienteDestino: string; usuarioDestino: string; skilDestino: string } = {
|
||||
clienteDestino: '',
|
||||
usuarioDestino: '',
|
||||
skilDestino: ''
|
||||
};
|
||||
reservaTransferLastResult: AssignReservaLinesResultDto | null = null;
|
||||
moveToReservaLastResult: AssignReservaLinesResultDto | null = null;
|
||||
|
||||
detailData: any = null;
|
||||
financeData: any = null;
|
||||
editModel: any = null;
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.viewMode === 'GROUPS';
|
||||
}
|
||||
|
|
@ -546,9 +691,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
ngOnInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isGestor = this.authService.hasRole('gestor');
|
||||
this.isClientRestricted = !(this.isAdmin || this.isGestor);
|
||||
this.isClientRestricted = !(this.isSysAdmin || this.isGestor);
|
||||
|
||||
if (this.isClientRestricted) {
|
||||
this.filterSkil = 'ALL';
|
||||
|
|
@ -643,7 +788,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
|
||||
// ============================================================
|
||||
private anyModalOpen(): boolean {
|
||||
return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen);
|
||||
return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen);
|
||||
}
|
||||
|
||||
private cleanupModalArtifacts() {
|
||||
|
|
@ -669,6 +814,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.financeOpen = false;
|
||||
this.editOpen = false;
|
||||
this.createOpen = false;
|
||||
this.reservaTransferOpen = false;
|
||||
this.moveToReservaOpen = false;
|
||||
|
||||
this.detailData = null;
|
||||
this.financeData = null;
|
||||
|
|
@ -680,6 +827,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.editingId = null;
|
||||
this.batchDetailOpen = false;
|
||||
this.batchMassPreview = null;
|
||||
this.batchExcelPreview = null;
|
||||
this.batchExcelPreviewLoading = false;
|
||||
this.batchExcelTemplateDownloading = false;
|
||||
this.reservaTransferSaving = false;
|
||||
this.moveToReservaSaving = false;
|
||||
this.reservaTransferLastResult = null;
|
||||
this.moveToReservaLastResult = null;
|
||||
|
||||
// Limpa overlays/locks residuais
|
||||
this.cleanupModalArtifacts();
|
||||
|
|
@ -1399,8 +1553,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
toggleGroup(clientName: string) {
|
||||
if (this.expandedGroup === clientName) {
|
||||
this.expandedGroup = null;
|
||||
this.groupLines = [];
|
||||
this.clearReservaSelection();
|
||||
return;
|
||||
}
|
||||
this.clearReservaSelection();
|
||||
this.expandedGroup = clientName;
|
||||
|
||||
const term = (this.searchTerm ?? '').trim();
|
||||
|
|
@ -1412,6 +1569,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
fetchGroupLines(clientName: string, search?: string) {
|
||||
const requestVersion = ++this.linesRequestVersion;
|
||||
this.groupLines = [];
|
||||
this.clearReservaSelection();
|
||||
this.loadingLines = true;
|
||||
|
||||
let params = new HttpParams()
|
||||
|
|
@ -1432,6 +1590,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
id: x.id,
|
||||
item: String(x.item ?? ''),
|
||||
linha: x.linha ?? '',
|
||||
chip: x.chip ?? '',
|
||||
cliente: x.cliente ?? '',
|
||||
usuario: x.usuario ?? '',
|
||||
status: x.status ?? '',
|
||||
|
|
@ -1601,7 +1760,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
async onImportExcel() {
|
||||
if (!this.isAdmin) {
|
||||
if (!this.isSysAdmin) {
|
||||
await this.showToast('Você não tem permissão para importar planilha.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -1612,7 +1771,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onExcelSelected(ev: Event) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
|
@ -1802,7 +1961,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
async onRemover(r: LineRow, fromGroup = false) {
|
||||
if (!this.isAdmin) {
|
||||
if (!this.isSysAdmin) {
|
||||
await this.showToast('Apenas sysadmin pode remover linhas.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -1913,6 +2072,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.batchMassInputText = '';
|
||||
this.batchMassSeparatorMode = 'AUTO';
|
||||
this.batchMassPreview = null;
|
||||
this.batchExcelPreview = null;
|
||||
this.batchExcelPreviewLoading = false;
|
||||
this.createBatchValidationByUid = {};
|
||||
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
|
||||
this.createSaving = false;
|
||||
|
|
@ -2402,12 +2563,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
private recomputeBatchValidation() {
|
||||
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) => {
|
||||
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
|
||||
if (!linhaDigits) return;
|
||||
counts.set(linhaDigits, (counts.get(linhaDigits) ?? 0) + 1);
|
||||
linhaCounts.set(linhaDigits, (linhaCounts.get(linhaDigits) ?? 0) + 1);
|
||||
});
|
||||
|
||||
this.createBatchLines.forEach((row) => {
|
||||
const chipDigits = (row?.chip ?? '').toString().replace(/\D/g, '');
|
||||
if (!chipDigits) return;
|
||||
chipCounts.set(chipDigits, (chipCounts.get(chipDigits) ?? 0) + 1);
|
||||
});
|
||||
|
||||
let valid = 0;
|
||||
|
|
@ -2418,12 +2586,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
const linhaRaw = (row?.linha ?? '').toString().trim();
|
||||
const chipRaw = (row?.chip ?? '').toString().trim();
|
||||
const linhaDigits = linhaRaw.replace(/\D/g, '');
|
||||
const chipDigits = chipRaw.replace(/\D/g, '');
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!linhaRaw) errors.push('Linha obrigatória.');
|
||||
else if (!linhaDigits) errors.push('Número de linha inválido.');
|
||||
|
||||
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
|
||||
else if (!chipDigits) errors.push('Chip (ICCID) inválido.');
|
||||
|
||||
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
|
||||
const conta = (row?.['conta'] ?? '').toString().trim();
|
||||
|
|
@ -2439,9 +2609,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.');
|
||||
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
|
||||
|
||||
const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1;
|
||||
if (isDuplicate) {
|
||||
const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1;
|
||||
const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1;
|
||||
if (isLinhaDuplicate) {
|
||||
errors.push('Linha duplicada no lote.');
|
||||
}
|
||||
if (isChipDuplicate) {
|
||||
errors.push('Chip (ICCID) duplicado no lote.');
|
||||
}
|
||||
if (isLinhaDuplicate || isChipDuplicate) {
|
||||
duplicates++;
|
||||
}
|
||||
|
||||
|
|
@ -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() {
|
||||
const clientError = this.validateCreateClientFields();
|
||||
if (clientError) {
|
||||
|
|
|
|||
|
|
@ -87,11 +87,11 @@
|
|||
[disabled]="loading">
|
||||
</app-select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>Usuário (ID)</label>
|
||||
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" />
|
||||
<div class="filter-field filter-user">
|
||||
<label>Usuário</label>
|
||||
<input type="text" placeholder="Nome ou e-mail do usuário" [(ngModel)]="filterUser" [disabled]="loading" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<div class="filter-field filter-search">
|
||||
<label>Busca geral</label>
|
||||
<div class="input-group input-group-sm search-group">
|
||||
<span class="input-group-text">
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
<td>
|
||||
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="entity-col">
|
||||
<div class="entity-cell">
|
||||
<div class="entity-label td-clip" [title]="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>
|
||||
</button>
|
||||
</div>
|
||||
<small class="entity-id" *ngIf="log.entityId">{{ log.entityId }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="details-row" *ngIf="expandedLogId === log.id">
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@
|
|||
.filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
|
|
@ -224,6 +225,9 @@
|
|||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
|
|
@ -244,11 +248,25 @@
|
|||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-user {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-group {
|
||||
max-width: 270px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(17, 18, 20, 0.15);
|
||||
|
|
@ -262,6 +280,7 @@
|
|||
}
|
||||
|
||||
.input-group-text {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(17, 18, 20, 0.5);
|
||||
|
|
@ -272,10 +291,13 @@
|
|||
}
|
||||
|
||||
.form-control {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
padding: 10px 0;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
|
|
@ -285,6 +307,7 @@
|
|||
}
|
||||
|
||||
.btn-clear {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(17, 18, 20, 0.45);
|
||||
|
|
@ -399,7 +422,8 @@
|
|||
|
||||
.table-modern th:nth-child(5),
|
||||
.table-modern td:nth-child(5) {
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.table-modern th:nth-child(2),
|
||||
|
|
@ -447,13 +471,15 @@
|
|||
.entity-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entity-label {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
|
|
@ -677,10 +703,10 @@
|
|||
.entity-cell {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.entity-label { flex: 1 1 auto; min-width: 0; }
|
||||
.entity-label { flex: 1 1 auto; min-width: 0; text-align: center; }
|
||||
.expand-btn { align-self: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
|
|
@ -870,18 +896,14 @@
|
|||
}
|
||||
|
||||
.entity-cell {
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.entity-label {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
margin-top: 2px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.details-row td {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export class Historico implements OnInit {
|
|||
|
||||
filterPageName = '';
|
||||
filterAction = '';
|
||||
filterUserId = '';
|
||||
filterUser = '';
|
||||
filterSearch = '';
|
||||
dateFrom = '';
|
||||
dateTo = '';
|
||||
|
|
@ -84,7 +84,7 @@ export class Historico implements OnInit {
|
|||
clearFilters(): void {
|
||||
this.filterPageName = '';
|
||||
this.filterAction = '';
|
||||
this.filterUserId = '';
|
||||
this.filterUser = '';
|
||||
this.filterSearch = '';
|
||||
this.dateFrom = '';
|
||||
this.dateTo = '';
|
||||
|
|
@ -221,7 +221,7 @@ export class Historico implements OnInit {
|
|||
pageSize: this.pageSize,
|
||||
pageName: this.filterPageName || undefined,
|
||||
action: this.filterAction || undefined,
|
||||
userId: this.filterUserId?.trim() || undefined,
|
||||
user: this.filterUser?.trim() || undefined,
|
||||
search: this.filterSearch?.trim() || undefined,
|
||||
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
|
||||
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
type="email"
|
||||
id="email"
|
||||
formControlName="username"
|
||||
placeholder="admin@empresa.com"
|
||||
placeholder="usuario@empresa.com"
|
||||
[class.error]="hasError('username')"
|
||||
>
|
||||
<div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<i class="bi bi-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
|
||||
placeholder="Pesquisar..."
|
||||
[(ngModel)]="search"
|
||||
(ngModelChange)="clearSelection()"
|
||||
/>
|
||||
|
|
@ -115,8 +115,8 @@
|
|||
class="list-item"
|
||||
*ngFor="let n of filteredNotifications"
|
||||
[class.is-read]="n.lida"
|
||||
[class.is-danger]="getNotificationTipo(n) === 'Vencido'"
|
||||
[class.is-warning]="getNotificationTipo(n) === 'AVencer'"
|
||||
[class.is-danger]="isVencido(n)"
|
||||
[class.is-warning]="isAVencer(n)"
|
||||
>
|
||||
<div class="status-strip"></div>
|
||||
|
||||
|
|
@ -126,7 +126,12 @@
|
|||
</label>
|
||||
|
||||
<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 class="item-content">
|
||||
|
|
@ -156,8 +161,8 @@
|
|||
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
|
||||
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
||||
<span class="badge-tag" [class.danger]="isVencido(n)" [class.warn]="isAVencer(n)" [class.info]="isAutoRenew(n)">
|
||||
{{ getStatusLabel(n) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -173,6 +178,27 @@
|
|||
<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>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ $border: #e5e7eb;
|
|||
|
||||
.bi-x-circle-fill { color: $danger; }
|
||||
.bi-clock-fill { color: $warning; }
|
||||
.bi-check2-circle { color: $primary; }
|
||||
}
|
||||
|
||||
.item-content { flex: 1; min-width: 0; }
|
||||
|
|
@ -290,9 +291,9 @@ $border: #e5e7eb;
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
min-width: 170px;
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
|
|
@ -323,17 +324,22 @@ $border: #e5e7eb;
|
|||
|
||||
&.danger { background: rgba($danger, 0.1); color: $danger; }
|
||||
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
|
||||
&.info { background: rgba($primary, 0.12); color: $primary; }
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
margin-left: 12px; align-self: center;
|
||||
margin-left: 12px;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: white; border: 1px solid $border;
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
padding: 6px 10px; border-radius: 7px;
|
||||
cursor: pointer;
|
||||
color: $text-main; font-size: 13px; font-weight: 600;
|
||||
color: $text-main; font-size: 12px; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
|
|
@ -344,6 +350,18 @@ $border: #e5e7eb;
|
|||
@media(min-width: 768px) { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.btn-action.ghost {
|
||||
background: rgba($primary, 0.06);
|
||||
border-color: rgba($primary, 0.25);
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.btn-action.renew {
|
||||
background: rgba($warning, 0.12);
|
||||
border-color: rgba($warning, 0.35);
|
||||
color: color.adjust($warning, $lightness: -22%);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
RESPONSIVIDADE MOBILE (Central de Notificações)
|
||||
========================================================================== */
|
||||
|
|
@ -634,14 +652,17 @@ $border: #e5e7eb;
|
|||
grid-column: 1 / -1;
|
||||
margin: 2px 0 0 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +748,7 @@ $border: #e5e7eb;
|
|||
}
|
||||
|
||||
.btn-action {
|
||||
font-size: 11px;
|
||||
padding: 7px 8px;
|
||||
font-size: 10px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
||||
import { VigenciaService } from '../../services/vigencia.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notificacoes',
|
||||
|
|
@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
bulkUnreadLoading = false;
|
||||
exportLoading = false;
|
||||
selectedIds = new Set<string>();
|
||||
renewingKey: string | null = null;
|
||||
private readonly subs = new Subscription();
|
||||
|
||||
constructor(private notificationsService: NotificationsService) {}
|
||||
constructor(
|
||||
private notificationsService: NotificationsService,
|
||||
private router: Router,
|
||||
private vigenciaService: VigenciaService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadNotifications();
|
||||
|
|
@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
return parsed.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
|
||||
getNotificationTipo(notification: NotificationDto): string {
|
||||
if (notification.tipo === 'RenovacaoAutomatica') {
|
||||
return 'RenovacaoAutomatica';
|
||||
}
|
||||
|
||||
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||
const parsed = this.parseDateOnly(reference);
|
||||
if (!parsed) return notification.tipo;
|
||||
|
|
@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
return parsed < today ? 'Vencido' : 'AVencer';
|
||||
}
|
||||
|
||||
isVencido(notification: NotificationDto): boolean {
|
||||
return this.getNotificationTipo(notification) === 'Vencido';
|
||||
}
|
||||
|
||||
isAVencer(notification: NotificationDto): boolean {
|
||||
return this.getNotificationTipo(notification) === 'AVencer';
|
||||
}
|
||||
|
||||
isAutoRenew(notification: NotificationDto): boolean {
|
||||
return this.getNotificationTipo(notification) === 'RenovacaoAutomatica';
|
||||
}
|
||||
|
||||
getStatusLabel(notification: NotificationDto): string {
|
||||
if (this.isAutoRenew(notification)) return 'Renovação automática';
|
||||
return this.isVencido(notification) ? 'Vencido' : 'A vencer';
|
||||
}
|
||||
|
||||
goToVigencia(notification: NotificationDto): void {
|
||||
const id = (notification.vigenciaLineId ?? '').trim();
|
||||
const linha = (notification.linha ?? '').trim();
|
||||
if (!id && !linha) return;
|
||||
|
||||
this.router.navigate(['/vigencia'], {
|
||||
queryParams: { lineId: id || null, linha: linha || null, open: 'edit' }
|
||||
});
|
||||
}
|
||||
|
||||
renewFromNotification(notification: NotificationDto): void {
|
||||
if (!this.isAVencer(notification)) return;
|
||||
const years = 2;
|
||||
const lockKey = notification.id;
|
||||
if (this.renewingKey === lockKey) return;
|
||||
this.renewingKey = lockKey;
|
||||
|
||||
const vigenciaLineId = (notification.vigenciaLineId ?? '').trim();
|
||||
if (vigenciaLineId) {
|
||||
this.scheduleByVigenciaId(vigenciaLineId);
|
||||
return;
|
||||
}
|
||||
|
||||
const linha = (notification.linha ?? '').trim();
|
||||
if (!linha) {
|
||||
this.renewingKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const onlyDigits = linha.replace(/\D/g, '');
|
||||
const lookup = onlyDigits || linha;
|
||||
this.vigenciaService.getVigencia({
|
||||
search: lookup,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sortBy: 'item',
|
||||
sortDir: 'asc'
|
||||
}).subscribe({
|
||||
next: (res) => {
|
||||
const rows = res?.items ?? [];
|
||||
const found = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0];
|
||||
const id = (found?.id ?? '').trim();
|
||||
if (!id) {
|
||||
this.renewingKey = null;
|
||||
return;
|
||||
}
|
||||
this.scheduleByVigenciaId(id);
|
||||
},
|
||||
error: () => {
|
||||
this.renewingKey = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isRenewing(notification: NotificationDto): boolean {
|
||||
return this.renewingKey === notification.id;
|
||||
}
|
||||
|
||||
private scheduleByVigenciaId(id: string): void {
|
||||
const years = 2;
|
||||
this.vigenciaService.configureAutoRenew(id, { years }).subscribe({
|
||||
next: () => {
|
||||
this.renewingKey = null;
|
||||
this.loadNotifications();
|
||||
},
|
||||
error: () => {
|
||||
this.renewingKey = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadNotifications() {
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
|
|
@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
|
||||
private shouldMarkRead(n: NotificationDto): boolean {
|
||||
if (this.filter === 'todas') return true;
|
||||
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer';
|
||||
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido';
|
||||
if (this.filter === 'aVencer') return this.isAVencer(n);
|
||||
if (this.filter === 'vencidas') return this.isVencido(n);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
return this.notifications.filter(n => n.lida);
|
||||
}
|
||||
if (this.filter === 'vencidas') {
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
|
||||
return this.notifications.filter(n => !n.lida && this.isVencido(n));
|
||||
}
|
||||
if (this.filter === 'aVencer') {
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
|
||||
return this.notifications.filter(n => !n.lida && this.isAVencer(n));
|
||||
}
|
||||
// "todas" aqui representa o inbox: pendentes (não lidas).
|
||||
return this.notifications.filter(n => !n.lida);
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
type="button"
|
||||
title="Excluir"
|
||||
aria-label="Excluir"
|
||||
*ngIf="isAdmin"
|
||||
*ngIf="isSysAdmin"
|
||||
(click)="remove.emit(row)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export class ParcelamentosTableComponent {
|
|||
@Input() items: ParcelamentoViewItem[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() errorMessage = '';
|
||||
@Input() isAdmin = false;
|
||||
@Input() isSysAdmin = false;
|
||||
|
||||
@Input() segment: ParcelamentoSegment = 'todos';
|
||||
@Input() segmentCounts: Record<ParcelamentoSegment, number> = {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
[total]="total"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[isAdmin]="isAdmin"
|
||||
[isSysAdmin]="isSysAdmin"
|
||||
(segmentChange)="setSegment($event)"
|
||||
(detail)="openDetails($event)"
|
||||
(edit)="openEdit($event)"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
kpiCards: ParcelamentoKpi[] = [];
|
||||
activeChips: FilterChip[] = [];
|
||||
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
|
||||
detailOpen = false;
|
||||
detailLoading = false;
|
||||
|
|
@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private syncPermissions(): void {
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
|
|
@ -440,7 +440,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openDelete(item: ParcelamentoViewItem): void {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.deleteTarget = item;
|
||||
this.deleteError = '';
|
||||
this.deleteOpen = true;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="card-shell">
|
||||
<header class="card-header">
|
||||
<div class="title-badge">
|
||||
<i class="bi bi-shield-lock-fill"></i> SYSTEM ADMIN
|
||||
<i class="bi bi-shield-lock-fill"></i> SYSADMIN
|
||||
</div>
|
||||
<h1>Fornecer Usuário para Cliente</h1>
|
||||
<p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import {
|
|||
} from '@angular/forms';
|
||||
|
||||
import {
|
||||
SystemAdminService,
|
||||
SysadminService,
|
||||
SystemTenantDto,
|
||||
CreateSystemTenantUserResponse,
|
||||
} from '../../services/system-admin.service';
|
||||
} from '../../services/sysadmin.service';
|
||||
|
||||
type RoleOption = {
|
||||
value: string;
|
||||
|
|
@ -50,7 +50,7 @@ export class SystemProvisionUserPage implements OnInit {
|
|||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private systemAdminService: SystemAdminService
|
||||
private sysadminService: SysadminService
|
||||
) {
|
||||
this.provisionForm = this.fb.group(
|
||||
{
|
||||
|
|
@ -75,7 +75,7 @@ export class SystemProvisionUserPage implements OnInit {
|
|||
this.tenantsLoading = true;
|
||||
this.tenantsError = '';
|
||||
|
||||
this.systemAdminService
|
||||
this.sysadminService
|
||||
.listTenants({ source: this.sourceType, active: true })
|
||||
.subscribe({
|
||||
next: (tenants) => {
|
||||
|
|
@ -133,7 +133,7 @@ export class SystemProvisionUserPage implements OnInit {
|
|||
this.submitting = true;
|
||||
this.setFormDisabled(true);
|
||||
|
||||
this.systemAdminService
|
||||
this.sysadminService
|
||||
.createTenantUser(tenantId, {
|
||||
name: nameRaw,
|
||||
email,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<small class="subtitle">Controle de contratos e fidelização</small>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
<th>LINHA</th>
|
||||
<th>CONTA</th>
|
||||
<th>USUÁRIO</th>
|
||||
<th>PLANO</th>
|
||||
<th class="plano-col">PLANO</th>
|
||||
<th>EFETIVAÇÃO</th>
|
||||
<th>VENCIMENTO</th>
|
||||
<th class="text-end">TOTAL</th>
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
<td class="fw-black text-blue">{{ row.linha }}</td>
|
||||
<td class="text-dark small">{{ row.conta || '-' }}</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">
|
||||
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
|
||||
|
|
@ -146,11 +146,19 @@
|
|||
{{ (row.total || 0) | currency:'BRL' }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<td class="actions-col">
|
||||
<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 *ngIf="isAdmin" 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 primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -236,6 +244,12 @@
|
|||
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
|
||||
</span>
|
||||
</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">
|
||||
<span class="lbl">Valor Total</span>
|
||||
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
|
||||
|
|
|
|||
|
|
@ -286,9 +286,12 @@
|
|||
.chip-muted { font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); }
|
||||
|
||||
/* TABELA MUREG STYLE */
|
||||
.inner-table-wrap { max-height: 500px; overflow-y: auto; }
|
||||
.table-wrap { overflow: auto; }
|
||||
.inner-table-wrap { max-height: 500px; overflow: auto; }
|
||||
.table-modern {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
thead th {
|
||||
position: sticky; top: 0; z-index: 10; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px);
|
||||
border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px;
|
||||
|
|
@ -301,9 +304,26 @@
|
|||
.fw-black { font-weight: 950; }
|
||||
.text-brand { color: var(--brand) !important; }
|
||||
.text-blue { color: var(--blue) !important; }
|
||||
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.td-clip {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.actions-col { min-width: 152px; }
|
||||
.plano-col {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 280px;
|
||||
width: 280px;
|
||||
text-align: center;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
|
|
@ -312,6 +332,27 @@
|
|||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.renew-chip {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
color: #92400e;
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
border: 1px solid rgba(245, 158, 11, 0.38);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
min-height: 26px;
|
||||
padding: 4px 7px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
|
|
@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
|||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 120px;
|
||||
min-width: 210px;
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
|
|
@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
|||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 106px;
|
||||
min-width: 190px;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
|
||||
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
|
@ -98,30 +100,34 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
clientsFromGeral: string[] = [];
|
||||
planOptions: string[] = [];
|
||||
|
||||
isAdmin = false;
|
||||
isSysAdmin = false;
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: ToastType = 'success';
|
||||
private toastTimer: any = null;
|
||||
private searchTimer: any = null;
|
||||
private readonly subs = new Subscription();
|
||||
|
||||
constructor(
|
||||
private vigenciaService: VigenciaService,
|
||||
private authService: AuthService,
|
||||
private linesService: LinesService,
|
||||
private planAutoFill: PlanAutoFillService
|
||||
private planAutoFill: PlanAutoFillService,
|
||||
private route: ActivatedRoute
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.loadClients();
|
||||
this.loadPlanRules();
|
||||
this.fetch(1);
|
||||
this.bindOpenFromNotificationQuery();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||
this.subs.unsubscribe();
|
||||
}
|
||||
|
||||
setView(mode: ViewMode): void {
|
||||
|
|
@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
return this.startOfDay(d) >= this.startOfDay(new Date());
|
||||
}
|
||||
|
||||
public isAVencer(dateValue: any): boolean {
|
||||
if (!dateValue) return false;
|
||||
const d = this.parseAnyDate(dateValue);
|
||||
if (!d) return false;
|
||||
const today = this.startOfDay(new Date());
|
||||
const end = this.startOfDay(d);
|
||||
const days = Math.round((end.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
||||
return days >= 0 && days <= 30;
|
||||
}
|
||||
|
||||
getRenewalBadge(row: VigenciaRow): string {
|
||||
if (!row.autoRenewYears) return '';
|
||||
return `Auto +${row.autoRenewYears} ano(s)`;
|
||||
}
|
||||
|
||||
public parseAnyDate(value: any): Date | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
|
|
@ -273,11 +294,27 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.fetch(1);
|
||||
}
|
||||
|
||||
scheduleAutoRenew(row: VigenciaRow): void {
|
||||
if (!row?.id) return;
|
||||
const years = 2;
|
||||
|
||||
this.vigenciaService.configureAutoRenew(row.id, { years }).subscribe({
|
||||
next: () => {
|
||||
row.autoRenewYears = years;
|
||||
row.autoRenewReferenceEndDate = row.dtTerminoFidelizacao;
|
||||
row.autoRenewConfiguredAt = new Date().toISOString();
|
||||
this.showToast(`Renovação automática (+${years} ano${years > 1 ? 's' : ''}) programada.`, 'success');
|
||||
},
|
||||
error: () => this.showToast('Não foi possível programar a renovação automática.', 'danger')
|
||||
});
|
||||
}
|
||||
|
||||
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
|
||||
closeDetails() { this.detailsOpen = false; }
|
||||
|
||||
openEdit(r: VigenciaRow) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
|
||||
|
|
@ -328,7 +365,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.preloadGeralClients();
|
||||
|
|
@ -507,7 +544,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openDelete(r: VigenciaRow) {
|
||||
if (!this.isAdmin) return;
|
||||
if (!this.isSysAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
|
@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
private bindOpenFromNotificationQuery(): void {
|
||||
this.subs.add(
|
||||
this.route.queryParamMap.subscribe((params) => {
|
||||
const lineId = (params.get('lineId') ?? '').trim();
|
||||
const linha = (params.get('linha') ?? '').trim();
|
||||
if (!lineId && !linha) return;
|
||||
|
||||
const openMode = (params.get('open') ?? 'edit').trim().toLowerCase();
|
||||
if (lineId) {
|
||||
this.openVigenciaLineById(lineId, openMode);
|
||||
} else if (linha) {
|
||||
this.openVigenciaLineByNumber(linha, openMode);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private openVigenciaLineById(lineId: string, openMode: string): void {
|
||||
this.vigenciaService.getById(lineId).subscribe({
|
||||
next: (row) => {
|
||||
if (this.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) {
|
||||
this.loading = false;
|
||||
this.expandedLoading = false;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export interface HistoricoQuery {
|
|||
pageName?: string;
|
||||
action?: AuditAction | string;
|
||||
entity?: string;
|
||||
userId?: string;
|
||||
user?: string;
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
|
|
@ -64,7 +64,7 @@ export class HistoricoService {
|
|||
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
|
||||
if (params.action) httpParams = httpParams.set('action', params.action);
|
||||
if (params.entity) httpParams = httpParams.set('entity', params.entity);
|
||||
if (params.userId) httpParams = httpParams.set('userId', params.userId);
|
||||
if (params.user) httpParams = httpParams.set('user', params.user);
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
|
||||
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Observable, Subject, tap } from 'rxjs';
|
|||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export type NotificationTipo = 'AVencer' | 'Vencido';
|
||||
export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
|
||||
|
||||
export type NotificationDto = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export type CreateSystemTenantUserResponse = {
|
|||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SystemAdminService {
|
||||
export class SysadminService {
|
||||
private readonly baseApi: string;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
|
|
@ -22,6 +22,10 @@ export interface VigenciaRow {
|
|||
planoContrato: string | null;
|
||||
dtEfetivacaoServico: string | null;
|
||||
dtTerminoFidelizacao: string | null;
|
||||
autoRenewYears?: number | null;
|
||||
autoRenewReferenceEndDate?: string | null;
|
||||
autoRenewConfiguredAt?: string | null;
|
||||
lastAutoRenewedAt?: string | null;
|
||||
total: number | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
|
|
@ -40,6 +44,9 @@ export interface UpdateVigenciaRequest {
|
|||
}
|
||||
|
||||
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
|
||||
export interface ConfigureVigenciaRenewalRequest {
|
||||
years: 2;
|
||||
}
|
||||
|
||||
export interface VigenciaClientGroup {
|
||||
cliente: string;
|
||||
|
|
@ -118,4 +125,8 @@ export class VigenciaService {
|
|||
remove(id: string): Observable<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue