Compare commits
No commits in common. "6a8f2d78bdf2a9364bfa394bb8e1a76bd4d26b97" and "96d1b28c19ab3ea832ca6abcb5c72c4e72223a67" have entirely different histories.
6a8f2d78bd
...
96d1b28c19
15
README.md
15
README.md
|
|
@ -57,18 +57,3 @@ 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`
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "20kB",
|
||||
"maximumError": "45kB"
|
||||
"maximumError": "40kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@
|
|||
"@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",
|
||||
|
|
@ -3733,16 +3732,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/beasties": {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
"@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",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 154 KiB |
|
|
@ -8,8 +8,7 @@ import { Mureg } from './pages/mureg/mureg';
|
|||
import { Faturamento } from './pages/faturamento/faturamento';
|
||||
|
||||
import { authGuard } from './guards/auth.guard';
|
||||
import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard';
|
||||
import { sysadminOnlyGuard } from './guards/sysadmin-only.guard';
|
||||
import { adminGuard } from './guards/admin.guard';
|
||||
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
||||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||
|
|
@ -20,7 +19,6 @@ import { Resumo } from './pages/resumo/resumo';
|
|||
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
|
||||
import { Historico } from './pages/historico/historico';
|
||||
import { Perfil } from './pages/perfil/perfil';
|
||||
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
|
|
@ -29,22 +27,16 @@ 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, sysadminOrGestorGuard], title: 'Faturamento' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], 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, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, adminGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||
{
|
||||
path: 'system/fornecer-usuario',
|
||||
component: SystemProvisionUserPage,
|
||||
canActivate: [authGuard, sysadminOnlyGuard],
|
||||
title: 'Criar Credenciais do Cliente',
|
||||
},
|
||||
|
||||
// ✅ rota correta
|
||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export class AppComponent {
|
|||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/perfil',
|
||||
'/system',
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -11,31 +11,10 @@
|
|||
</button>
|
||||
|
||||
<div class="app-select-panel" *ngIf="isOpen">
|
||||
<div class="app-select-search" *ngIf="searchable" (click)="$event.stopPropagation()">
|
||||
<i class="bi bi-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="app-select-search-input"
|
||||
[value]="searchTerm"
|
||||
[placeholder]="searchPlaceholder"
|
||||
(input)="onSearchInput($any($event.target).value)"
|
||||
(keydown)="onSearchKeydown($event)"
|
||||
/>
|
||||
<button
|
||||
*ngIf="searchTerm"
|
||||
type="button"
|
||||
class="app-select-search-clear"
|
||||
(click)="clearSearch($event)"
|
||||
aria-label="Limpar pesquisa"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-select-option"
|
||||
*ngFor="let opt of filteredOptions; trackBy: trackByValue"
|
||||
*ngFor="let opt of options; trackBy: trackByValue"
|
||||
[class.selected]="isSelected(opt)"
|
||||
(click)="selectOption(opt)"
|
||||
>
|
||||
|
|
@ -43,7 +22,7 @@
|
|||
<i class="bi bi-check2" *ngIf="isSelected(opt)"></i>
|
||||
</button>
|
||||
|
||||
<div class="app-select-empty" *ngIf="!filteredOptions || filteredOptions.length === 0">
|
||||
<div class="app-select-empty" *ngIf="!options || options.length === 0">
|
||||
Nenhuma opção
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -111,59 +111,6 @@
|
|||
padding: 6px;
|
||||
}
|
||||
|
||||
.app-select-search {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
|
||||
i {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-select-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.app-select-search-clear {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.app-select-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -23,12 +23,9 @@ export class CustomSelectComponent implements ControlValueAccessor {
|
|||
@Input() valueKey = 'value';
|
||||
@Input() size: 'sm' | 'md' = 'md';
|
||||
@Input() disabled = false;
|
||||
@Input() searchable = false;
|
||||
@Input() searchPlaceholder = 'Pesquisar...';
|
||||
|
||||
isOpen = false;
|
||||
value: any = null;
|
||||
searchTerm = '';
|
||||
|
||||
private onChange: (value: any) => void = () => {};
|
||||
private onTouched: () => void = () => {};
|
||||
|
|
@ -66,12 +63,10 @@ export class CustomSelectComponent implements ControlValueAccessor {
|
|||
toggle(): void {
|
||||
if (this.disabled) return;
|
||||
this.isOpen = !this.isOpen;
|
||||
if (!this.isOpen) this.searchTerm = '';
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.isOpen = false;
|
||||
this.searchTerm = '';
|
||||
}
|
||||
|
||||
selectOption(option: any): void {
|
||||
|
|
@ -89,26 +84,6 @@ export class CustomSelectComponent implements ControlValueAccessor {
|
|||
|
||||
trackByValue = (_: number, option: any) => this.getOptionValue(option);
|
||||
|
||||
get filteredOptions(): any[] {
|
||||
const opts = this.options || [];
|
||||
const term = this.normalizeText(this.searchTerm);
|
||||
if (!this.searchable || !term) return opts;
|
||||
return opts.filter((opt) => this.normalizeText(this.getOptionLabel(opt)).includes(term));
|
||||
}
|
||||
|
||||
onSearchInput(value: string): void {
|
||||
this.searchTerm = value ?? '';
|
||||
}
|
||||
|
||||
clearSearch(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.searchTerm = '';
|
||||
}
|
||||
|
||||
onSearchKeydown(event: KeyboardEvent): void {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private getOptionValue(option: any): any {
|
||||
if (option && typeof option === 'object') {
|
||||
return option[this.valueKey];
|
||||
|
|
@ -128,14 +103,6 @@ export class CustomSelectComponent implements ControlValueAccessor {
|
|||
return (this.options || []).find((o) => this.getOptionValue(o) === value);
|
||||
}
|
||||
|
||||
private normalizeText(value: string): string {
|
||||
return (value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.isOpen) return;
|
||||
|
|
|
|||
|
|
@ -9,24 +9,16 @@
|
|||
</button>
|
||||
|
||||
<a routerLink="/dashboard" class="logo-area" (click)="closeMenu()">
|
||||
<img src="linegestao-logo.png" alt="Line Gestão" class="logo-symbol" />
|
||||
<div class="lg-wordmark" [attr.aria-label]="headerLogoAriaLabel">
|
||||
<div class="lg-wordmark__line">Line</div>
|
||||
<div class="lg-wordmark__movel">{{ headerLogoSubtitle }}</div>
|
||||
<div class="logo-icon">
|
||||
<i class="bi bi-layers-fill"></i>
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
Line<span class="highlight">Gestão</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="logged-actions">
|
||||
<div class="client-header-context" *ngIf="isClientHeader">
|
||||
<div class="client-chip" [title]="clientTenantDisplayName || 'Cliente'">
|
||||
<span class="client-chip__icon" aria-hidden="true">
|
||||
<i class="bi bi-building"></i>
|
||||
</span>
|
||||
<span class="client-chip__name">{{ clientTenantDisplayNameAbbrev }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notifications-menu" [class.open]="notificationsOpen" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -192,19 +184,13 @@
|
|||
<button type="button" class="options-item" (click)="goToProfile()">
|
||||
<i class="bi bi-person-circle"></i> Perfil
|
||||
</button>
|
||||
<div class="divider" *ngIf="isSysAdmin"></div>
|
||||
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openCreateUserModal()">
|
||||
<div class="divider"></div>
|
||||
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openCreateUserModal()">
|
||||
<i class="bi bi-person-plus"></i> Criar novo usuário
|
||||
</button>
|
||||
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageUsersModal()">
|
||||
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
|
||||
<i class="bi bi-people"></i> Editar usuário
|
||||
</button>
|
||||
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageClientCredentialsModal()">
|
||||
<i class="bi bi-person-badge"></i> Credenciais de clientes
|
||||
</button>
|
||||
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="goToSystemProvisionUser()">
|
||||
<i class="bi bi-shield-lock"></i> Criar credenciais do cliente
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
<button type="button" class="options-item danger" (click)="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> Sair
|
||||
|
|
@ -217,11 +203,8 @@
|
|||
|
||||
<ng-template #publicHeader>
|
||||
<a routerLink="/" class="logo-area">
|
||||
<img src="linegestao-logo.png" alt="Line Gestão" class="logo-symbol" />
|
||||
<div class="lg-wordmark" aria-label="Line Gestão">
|
||||
<div class="lg-wordmark__line">Line</div>
|
||||
<div class="lg-wordmark__movel">Gestão</div>
|
||||
</div>
|
||||
<div class="logo-icon"><i class="bi bi-layers-fill"></i></div>
|
||||
<div class="logo-text">Line<span class="highlight">Gestão</span></div>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
|
||||
|
|
@ -307,7 +290,7 @@
|
|||
<div class="modal-overlay" *ngIf="manageUsersOpen" (click)="closeManageUsersModal()"></div>
|
||||
<div class="modal-card manage-users-modal" *ngIf="manageUsersOpen" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>{{ manageModalTitle }}</h3>
|
||||
<h3>Gestão de Usuários</h3>
|
||||
<button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
|
|
@ -317,7 +300,7 @@
|
|||
<div class="manage-search">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="text" [placeholder]="manageSearchPlaceholder" [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
|
||||
<input type="text" placeholder="Buscar por nome ou email..." [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -330,7 +313,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%;">Usuário</th>
|
||||
<th style="width: 25%;" class="text-center">Perfil</th>
|
||||
<th style="width: 25%;" class="text-center">Permissão</th>
|
||||
<th style="width: 15%;" class="text-center">Status</th>
|
||||
<th style="width: 20%;" class="text-center">Ações</th>
|
||||
</tr>
|
||||
|
|
@ -405,7 +388,7 @@
|
|||
<div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div>
|
||||
<div class="info-text">
|
||||
<h4>{{ target.nome }}</h4>
|
||||
<span>{{ isManageClientsMode ? 'Editando credencial do cliente' : 'Editando perfil' }}</span>
|
||||
<span>Editando perfil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -417,13 +400,13 @@
|
|||
<form class="user-form refined-form" id="editUserHeaderForm" [formGroup]="editUserForm" (ngSubmit)="submitEditUser()">
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label for="editHeaderNome">{{ isManageClientsMode ? 'Nome do responsável' : 'Nome Completo' }}</label>
|
||||
<label for="editHeaderNome">Nome Completo</label>
|
||||
<input id="editHeaderNome" type="text" formControlName="nome" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="editHeaderEmail">{{ isManageClientsMode ? 'Email de acesso' : 'Email Corporativo' }}</label>
|
||||
<label for="editHeaderEmail">Email Corporativo</label>
|
||||
<input id="editHeaderEmail" type="email" formControlName="email" />
|
||||
</div>
|
||||
|
||||
|
|
@ -441,13 +424,7 @@
|
|||
<div class="form-row two-col align-end">
|
||||
<div class="form-field">
|
||||
<label for="editHeaderPermissao">Nível de Acesso</label>
|
||||
<app-select
|
||||
id="editHeaderPermissao"
|
||||
formControlName="permissao"
|
||||
[options]="editPermissionOptions"
|
||||
labelKey="label"
|
||||
valueKey="value"
|
||||
placeholder="Selecione o nivel"></app-select>
|
||||
<app-select id="editHeaderPermissao" formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
|
|
@ -472,7 +449,7 @@
|
|||
(click)="confirmPermanentDeleteUser(target)"
|
||||
[disabled]="editUserSubmitting"
|
||||
[title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'">
|
||||
{{ isManageClientsMode ? 'Excluir Credencial' : 'Excluir Permanentemente' }}
|
||||
Excluir Permanentemente
|
||||
</button>
|
||||
<button type="button" class="btn-ghost" (click)="cancelEditUser()" [disabled]="editUserSubmitting">Cancelar</button>
|
||||
<button type="submit" form="editUserHeaderForm" class="btn-primary" [disabled]="editUserSubmitting || !editUserTarget">
|
||||
|
|
@ -487,8 +464,8 @@
|
|||
<div class="placeholder-icon">
|
||||
<i class="bi bi-person-gear"></i>
|
||||
</div>
|
||||
<h3>{{ isManageClientsMode ? 'Editar Credencial' : 'Editar Usuário' }}</h3>
|
||||
<p>{{ isManageClientsMode ? 'Selecione uma credencial de cliente para visualizar e editar os detalhes.' : 'Selecione um usuário na lista para visualizar e editar os detalhes.' }}</p>
|
||||
<h3>Editar Usuário</h3>
|
||||
<p>Selecione um usuário na lista para visualizar e editar os detalhes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -518,11 +495,8 @@
|
|||
<aside *ngIf="isLoggedHeader" class="side-menu" [class.open]="menuOpen" (click)="$event.stopPropagation()">
|
||||
<div class="side-menu-header">
|
||||
<a class="side-logo" routerLink="/dashboard" (click)="closeMenu()">
|
||||
<img src="linegestao-logo.png" alt="Line Gestão" class="side-logo-symbol" />
|
||||
<div class="side-wordmark" aria-label="Line Gestão">
|
||||
<div class="side-wordmark__line">Line</div>
|
||||
<div class="side-wordmark__movel">Gestão</div>
|
||||
</div>
|
||||
<span class="side-logo-icon"><i class="bi bi-layers-fill"></i></span>
|
||||
<span class="side-logo-text">Line<span class="highlight">Gestão</span></span>
|
||||
</a>
|
||||
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
|
@ -530,34 +504,34 @@
|
|||
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-table"></i> <span>Resumo</span>
|
||||
</a>
|
||||
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-sim"></i> <span>Geral</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-clock-history"></i> <span>Histórico</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a *ngIf="isAdmin" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
|
||||
</a>
|
||||
<a *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ $text-main: #111827;
|
|||
$text-muted: #6b7280;
|
||||
$bg-light: #f9fafb;
|
||||
$border-color: #e5e7eb;
|
||||
$logo-primary-blue-light: #004dcc;
|
||||
$logo-primary-purple-dark: #6a0dad;
|
||||
$logo-secondary-grey: #757575;
|
||||
|
||||
/* Utils */
|
||||
* { box-sizing: border-box; }
|
||||
|
|
@ -42,68 +39,15 @@ $logo-secondary-grey: #757575;
|
|||
}
|
||||
|
||||
.logo-area {
|
||||
display: flex; align-items: center; gap: 14px; text-decoration: none; color: #111827; min-width: 0;
|
||||
}
|
||||
|
||||
.logo-symbol {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 8px 14px rgba(106, 13, 173, 0.2));
|
||||
}
|
||||
|
||||
.lg-wordmark {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: 0.92;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
min-width: 0;
|
||||
--scale: 0.34;
|
||||
}
|
||||
|
||||
.lg-wordmark__line {
|
||||
font-family: 'Poppins', 'Nunito', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: calc(96px * var(--scale));
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#c8c3ff 0%,
|
||||
#7a6cff 26%,
|
||||
#4b3fe6 52%,
|
||||
#2b21c8 74%,
|
||||
#120a78 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 0;
|
||||
text-shadow: 0 1px 1px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.lg-wordmark__movel {
|
||||
font-family: 'Poppins', 'Nunito', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: calc(34px * var(--scale));
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
margin-left: calc(0.33em * var(--scale));
|
||||
margin-top: calc(-6px * var(--scale));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#6f7f96 0%,
|
||||
#4b5b72 48%,
|
||||
#2f3d52 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 0;
|
||||
text-shadow: 0 1px 1px rgba(15, 23, 42, 0.12);
|
||||
display: flex; align-items: center; gap: 10px; text-decoration: none; color: #111827;
|
||||
.logo-icon {
|
||||
width: 36px; height: 36px; background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
|
||||
color: #fff; border-radius: 50%; display: grid; place-items: center; font-size: 18px; box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 19px; font-weight: 700; letter-spacing: -0.5px;
|
||||
.highlight { background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
.nav-links { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 1; }
|
||||
|
|
@ -111,69 +55,16 @@ $logo-secondary-grey: #757575;
|
|||
display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s;
|
||||
&:hover { color: $primary; }
|
||||
}
|
||||
.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; }
|
||||
.header-actions { display: flex; align-items: center; }
|
||||
.btn-login-header {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px;
|
||||
border: 1px solid rgba(28, 56, 201, 0.18); background: #fff; color: $primary; font-weight: 700; font-size: 13px; text-decoration: none; transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
&:hover { transform: translateY(-1px); background: rgba(28, 56, 201, 0.04); box-shadow: 0 4px 12px rgba(28, 56, 201, 0.15); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .nav-links { display: none; } }
|
||||
.logged-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
|
||||
.client-header-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: min(390px, 38vw);
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.client-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 6px 12px 6px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(28, 56, 201, 0.2);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 55%, #eef2ff 100%);
|
||||
box-shadow: 0 8px 20px rgba(17, 24, 39, 0.08);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.client-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(17, 24, 39, 0.1);
|
||||
}
|
||||
|
||||
.client-chip__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2653d9 0%, #6a0dad 100%);
|
||||
box-shadow: 0 4px 10px rgba(38, 83, 217, 0.28);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-chip__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.15;
|
||||
max-width: min(290px, 27vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.header-inner.container {
|
||||
max-width: none;
|
||||
|
|
@ -745,7 +636,7 @@ $logo-secondary-grey: #757575;
|
|||
/* SIDE MENU */
|
||||
.menu-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1050; }
|
||||
.side-menu {
|
||||
position: fixed; top: 0; left: 0; height: 100vh; width: 280px; background: #fff; box-shadow: 4px 0 20px rgba(0,0,0,0.1); transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 1100; display: flex; flex-direction: column;
|
||||
position: fixed; top: 0; left: 0; height: 100vh; width: 260px; background: #fff; box-shadow: 4px 0 20px rgba(0,0,0,0.1); transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 1100; display: flex; flex-direction: column;
|
||||
&.open { transform: translateX(0); }
|
||||
}
|
||||
.side-menu-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid $border-color; }
|
||||
|
|
@ -763,74 +654,7 @@ $logo-secondary-grey: #757575;
|
|||
transition: color 0.2s ease;
|
||||
&:hover { color: $primary; }
|
||||
}
|
||||
.side-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: $text-main;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.side-logo-symbol {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 4px 9px rgba(106, 13, 173, 0.2));
|
||||
}
|
||||
|
||||
.side-wordmark {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
--scale: 0.23;
|
||||
}
|
||||
|
||||
.side-wordmark__line {
|
||||
font-family: 'Poppins', 'Nunito', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: calc(92px * var(--scale));
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#c8c3ff 0%,
|
||||
#7a6cff 26%,
|
||||
#4b3fe6 52%,
|
||||
#2b21c8 74%,
|
||||
#120a78 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.side-wordmark__movel {
|
||||
font-family: 'Poppins', 'Nunito', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: calc(92px * var(--scale));
|
||||
letter-spacing: -0.012em;
|
||||
white-space: nowrap;
|
||||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#aeb8c7 0%,
|
||||
#6b778d 50%,
|
||||
#3f4b60 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
.side-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: $text-main; font-weight: 700; .side-logo-icon { width: 32px; height: 32px; background: $primary; color: #fff; border-radius: 50%; display: grid; place-items: center; } }
|
||||
.side-menu-body { padding: 16px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.side-item {
|
||||
padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px;
|
||||
|
|
@ -861,46 +685,17 @@ $logo-secondary-grey: #757575;
|
|||
|
||||
.logo-area {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lg-wordmark {
|
||||
--scale: 0.29;
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-symbol {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.logged-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-header-context {
|
||||
max-width: min(310px, 34vw);
|
||||
}
|
||||
|
||||
.client-chip {
|
||||
gap: 8px;
|
||||
padding: 5px 10px 5px 6px;
|
||||
}
|
||||
|
||||
.client-chip__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.client-chip__name {
|
||||
font-size: 13px;
|
||||
max-width: min(240px, 24vw);
|
||||
}
|
||||
|
||||
.side-wordmark {
|
||||
--scale: 0.21;
|
||||
}
|
||||
|
||||
.notifications-dropdown {
|
||||
right: 0;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
|
|
@ -944,7 +739,6 @@ $logo-secondary-grey: #757575;
|
|||
|
||||
.header-inner {
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.logged-header {
|
||||
|
|
@ -973,51 +767,17 @@ $logo-secondary-grey: #757575;
|
|||
.logo-area {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-symbol {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
.logo-text {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lg-wordmark {
|
||||
--scale: 0.22;
|
||||
}
|
||||
|
||||
/* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */
|
||||
.header-inner > .logo-area {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-inner > .logo-area .lg-wordmark {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-inner > .logo-area .lg-wordmark {
|
||||
--scale: 0.2;
|
||||
}
|
||||
|
||||
.header-inner > .header-actions {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-inner > .header-actions .btn-login-header {
|
||||
padding: 7px 10px;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Header logado: mantém nome visível, porém menor para smartphone */
|
||||
.left-logged .logo-area .lg-wordmark {
|
||||
--scale: 0.2;
|
||||
}
|
||||
|
||||
.client-header-context {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logged-actions {
|
||||
|
|
@ -1049,23 +809,10 @@ $logo-secondary-grey: #757575;
|
|||
position: fixed;
|
||||
top: calc(var(--app-header-offset, 76px) + 8px);
|
||||
right: 8px;
|
||||
width: min(228px, calc(100vw - 16px));
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
width: min(260px, calc(100vw - 16px));
|
||||
z-index: 1250;
|
||||
}
|
||||
|
||||
.options-dropdown .options-item {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.options-dropdown .divider {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.notifications-head {
|
||||
padding: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1165,20 +912,6 @@ $logo-secondary-grey: #757575;
|
|||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 12px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-actions .btn-primary,
|
||||
.modal-actions .btn-secondary {
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.modal-card.manage-users-modal {
|
||||
width: calc(100vw - 12px);
|
||||
height: min(calc(100dvh - 12px), 680px);
|
||||
|
|
@ -1195,20 +928,6 @@ $logo-secondary-grey: #757575;
|
|||
max-height: 38vh;
|
||||
}
|
||||
|
||||
.manage-search {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.manage-search .search-input-wrapper input {
|
||||
height: 34px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manage-search .search-input-wrapper i {
|
||||
left: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manage-right-wrapper {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
|
|
@ -1218,58 +937,6 @@ $logo-secondary-grey: #757575;
|
|||
padding: 14px;
|
||||
}
|
||||
|
||||
.manage-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.manage-table {
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.manage-table thead th {
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
.manage-table tbody tr td {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-cell .avatar-mini {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-cell .user-info .u-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-cell .user-info .u-email {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.badge-role {
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.actions-group .btn-action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.edit-header-info {
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
|
|
@ -1316,31 +983,8 @@ $logo-secondary-grey: #757575;
|
|||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.header-inner > .logo-area {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-inner > .logo-area .logo-symbol {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-inner > .logo-area .lg-wordmark {
|
||||
--scale: 0.18;
|
||||
}
|
||||
|
||||
.header-inner > .header-actions .btn-login-header {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.left-logged .logo-area {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.left-logged .logo-area .lg-wordmark {
|
||||
--scale: 0.175;
|
||||
.logo-area .logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logged-actions {
|
||||
|
|
@ -1362,48 +1006,6 @@ $logo-secondary-grey: #757575;
|
|||
.options-dropdown {
|
||||
right: 6px;
|
||||
top: calc(var(--app-header-offset, 76px) + 6px);
|
||||
width: min(216px, calc(100vw - 12px));
|
||||
}
|
||||
|
||||
.options-dropdown .options-item {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.modal-actions .btn-primary,
|
||||
.modal-actions .btn-secondary {
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manage-table {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.manage-table thead th {
|
||||
padding: 7px 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.manage-table tbody tr td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-cell .avatar-mini {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
}
|
||||
|
||||
.actions-group .btn-action {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.notifications-head {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Component, HostListener, Inject, ElementRef, ViewChild, AfterViewInit,
|
|||
import { RouterLink, Router, NavigationEnd } from '@angular/router';
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
||||
|
|
@ -11,7 +10,6 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractContro
|
|||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { CustomSelectComponent } from '../custom-select/custom-select';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { confirmActionModal, confirmDeletionWithTyping, showDeletionWarning } from '../../utils/destructive-confirmation';
|
||||
|
||||
@Component({
|
||||
|
|
@ -33,11 +31,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
manageUsersOpen = false;
|
||||
isLoggedHeader = false;
|
||||
isHome = false;
|
||||
isSysAdmin = false;
|
||||
canViewAll = false;
|
||||
clientTenantDisplayName = '';
|
||||
private clientTenantNameTenantId: string | null = null;
|
||||
private readonly baseApi: string;
|
||||
isAdmin = false;
|
||||
notifications: NotificationDto[] = [];
|
||||
notificationsLoading = false;
|
||||
notificationsError = false;
|
||||
|
|
@ -58,16 +52,14 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
createUserForbidden = false;
|
||||
createUserSuccess = '';
|
||||
readonly permissionOptions = [
|
||||
{ value: 'sysadmin', label: 'SysAdmin' },
|
||||
{ value: 'admin', label: 'Administrador' },
|
||||
{ value: 'gestor', label: 'Gestor' },
|
||||
{ value: 'cliente', label: 'Cliente' },
|
||||
];
|
||||
|
||||
manageUsersLoading = false;
|
||||
manageUsersErrors: ApiFieldError[] = [];
|
||||
manageUsersSuccess = '';
|
||||
private manageUsersFeedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
manageMode: 'users' | 'clients' = 'users';
|
||||
manageUsers: any[] = [];
|
||||
manageSearch = '';
|
||||
managePage = 1;
|
||||
|
|
@ -94,7 +86,6 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/perfil',
|
||||
'/system',
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
|
@ -102,14 +93,10 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
private authService: AuthService,
|
||||
private notificationsService: NotificationsService,
|
||||
private usersService: UsersService,
|
||||
private http: HttpClient,
|
||||
private fb: FormBuilder,
|
||||
private hostElement: ElementRef<HTMLElement>,
|
||||
@Inject(PLATFORM_ID) private platformId: object
|
||||
) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
|
||||
this.createUserForm = this.fb.group(
|
||||
{
|
||||
nome: ['', [Validators.required, Validators.minLength(2)]],
|
||||
|
|
@ -212,24 +199,10 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
|
||||
private syncPermissions() {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
this.isSysAdmin = false;
|
||||
this.canViewAll = false;
|
||||
this.clientTenantDisplayName = '';
|
||||
this.clientTenantNameTenantId = null;
|
||||
this.isAdmin = false;
|
||||
return;
|
||||
}
|
||||
const isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
const isGestor = this.authService.hasRole('gestor');
|
||||
this.isSysAdmin = isSysAdmin;
|
||||
this.canViewAll = isSysAdmin || isGestor;
|
||||
|
||||
if (!this.isClientHeader) {
|
||||
this.clientTenantDisplayName = '';
|
||||
this.clientTenantNameTenantId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureClientTenantName();
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
|
|
@ -254,14 +227,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.router.navigate(['/perfil']);
|
||||
}
|
||||
|
||||
goToSystemProvisionUser() {
|
||||
if (!this.isSysAdmin) return;
|
||||
this.closeOptions();
|
||||
this.router.navigate(['/system/fornecer-usuario']);
|
||||
}
|
||||
|
||||
openCreateUserModal() {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.createUserOpen = true;
|
||||
this.closeOptions();
|
||||
this.resetCreateUserState();
|
||||
|
|
@ -273,17 +240,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
openManageUsersModal() {
|
||||
if (!this.isSysAdmin) return;
|
||||
this.manageMode = 'users';
|
||||
this.manageUsersOpen = true;
|
||||
this.closeOptions();
|
||||
this.resetManageUsersState();
|
||||
this.fetchManageUsers(1);
|
||||
}
|
||||
|
||||
openManageClientCredentialsModal() {
|
||||
if (!this.isSysAdmin) return;
|
||||
this.manageMode = 'clients';
|
||||
if (!this.isAdmin) return;
|
||||
this.manageUsersOpen = true;
|
||||
this.closeOptions();
|
||||
this.resetManageUsersState();
|
||||
|
|
@ -293,7 +250,6 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
closeManageUsersModal() {
|
||||
this.manageUsersOpen = false;
|
||||
this.resetManageUsersState();
|
||||
this.manageMode = 'users';
|
||||
}
|
||||
|
||||
toggleNotifications() {
|
||||
|
|
@ -387,10 +343,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
getVigenciaLabel(notification: NotificationDto): string {
|
||||
const tipo = this.getNotificationTipo(notification);
|
||||
if (tipo === 'Vencido') return 'Venceu em';
|
||||
if (tipo === 'AVencer') return 'Vence em';
|
||||
return 'Atualizado em';
|
||||
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em';
|
||||
}
|
||||
|
||||
getVigenciaDate(notification: NotificationDto): string {
|
||||
|
|
@ -404,11 +357,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
return parsed.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): string {
|
||||
if (notification.tipo === 'RenovacaoAutomatica') {
|
||||
return 'RenovacaoAutomatica';
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
|
||||
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||
const parsed = this.parseDateOnly(reference);
|
||||
if (!parsed) return notification.tipo;
|
||||
|
|
@ -454,22 +403,6 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
return this.notifications.filter(n => !n.lida).length;
|
||||
}
|
||||
|
||||
get isClientHeader(): boolean {
|
||||
return this.isLoggedHeader && !this.canViewAll;
|
||||
}
|
||||
|
||||
get headerLogoSubtitle(): string {
|
||||
return this.isClientHeader ? 'Gestão Empresas' : 'Gestão';
|
||||
}
|
||||
|
||||
get headerLogoAriaLabel(): string {
|
||||
return `Line ${this.headerLogoSubtitle}`;
|
||||
}
|
||||
|
||||
get clientTenantDisplayNameAbbrev(): string {
|
||||
return this.abbreviateClientTenantName(this.clientTenantDisplayName);
|
||||
}
|
||||
|
||||
get notificationsVisible() {
|
||||
return this.notificationsView === 'lidas'
|
||||
? this.notifications.filter(n => n.lida)
|
||||
|
|
@ -496,8 +429,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.authService.logout();
|
||||
this.optionsOpen = false;
|
||||
this.notificationsOpen = false;
|
||||
this.isSysAdmin = false;
|
||||
this.canViewAll = false;
|
||||
this.isAdmin = false;
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
|
|
@ -659,7 +591,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.createUserForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!this.isSysAdmin) {
|
||||
if (!this.isAdmin) {
|
||||
this.createUserForbidden = true;
|
||||
return;
|
||||
}
|
||||
|
|
@ -707,7 +639,6 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.usersService
|
||||
.list({
|
||||
search: this.manageSearch?.trim() || undefined,
|
||||
permissao: this.isManageClientsMode ? 'cliente' : undefined,
|
||||
page: this.managePage,
|
||||
pageSize: this.managePageSize,
|
||||
})
|
||||
|
|
@ -768,23 +699,17 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.usersService.getById(user.id).subscribe({
|
||||
next: (full) => {
|
||||
this.editUserTarget = full;
|
||||
const permissao = this.isManageClientsMode ? 'cliente' : (full.permissao ?? '');
|
||||
this.editUserForm.reset({
|
||||
nome: full.nome ?? '',
|
||||
email: full.email ?? '',
|
||||
senha: '',
|
||||
confirmarSenha: '',
|
||||
permissao,
|
||||
permissao: full.permissao ?? '',
|
||||
ativo: full.ativo ?? true,
|
||||
});
|
||||
if (this.isManageClientsMode) {
|
||||
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.editUserForm.get('permissao')?.enable({ emitEvent: false });
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.editUserErrors = [{ message: this.isManageClientsMode ? 'Erro ao carregar credencial do cliente.' : 'Erro ao carregar usuário.' }];
|
||||
this.editUserErrors = [{ message: 'Erro ao carregar usuario.' }];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -804,21 +729,12 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
const payload: any = {};
|
||||
const nome = (this.editUserForm.get('nome')?.value || '').toString().trim();
|
||||
const email = (this.editUserForm.get('email')?.value || '').toString().trim();
|
||||
const permissao = this.isManageClientsMode
|
||||
? 'cliente'
|
||||
: (this.editUserForm.get('permissao')?.value || '').toString().trim();
|
||||
const permissao = (this.editUserForm.get('permissao')?.value || '').toString().trim();
|
||||
const ativo = !!this.editUserForm.get('ativo')?.value;
|
||||
|
||||
if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome;
|
||||
if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email;
|
||||
if (this.isManageClientsMode) {
|
||||
const targetPermissao = String(this.editUserTarget.permissao || '').trim().toLowerCase();
|
||||
if (targetPermissao !== 'cliente') {
|
||||
payload.permissao = 'cliente';
|
||||
}
|
||||
} else if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) {
|
||||
payload.permissao = permissao;
|
||||
}
|
||||
if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) payload.permissao = permissao;
|
||||
if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo;
|
||||
|
||||
const senha = (this.editUserForm.get('senha')?.value || '').toString();
|
||||
|
|
@ -857,25 +773,18 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
const merged = this.mergeUserUpdate(currentTarget, payload);
|
||||
this.editUserSubmitting = false;
|
||||
this.setEditFormDisabled(false);
|
||||
this.editUserSuccess = this.isManageClientsMode
|
||||
? `Credencial de ${merged.nome} atualizada com sucesso.`
|
||||
: `Usuario ${merged.nome} atualizado com sucesso.`;
|
||||
this.editUserSuccess = `Usuario ${merged.nome} atualizado com sucesso.`;
|
||||
this.editUserTarget = merged;
|
||||
this.editUserForm.patchValue({
|
||||
nome: merged.nome ?? '',
|
||||
email: merged.email ?? '',
|
||||
permissao: this.isManageClientsMode ? 'cliente' : (merged.permissao ?? ''),
|
||||
permissao: merged.permissao ?? '',
|
||||
ativo: merged.ativo ?? true,
|
||||
senha: '',
|
||||
confirmarSenha: '',
|
||||
});
|
||||
this.upsertManageUser(merged);
|
||||
this.showManageUsersFeedback(
|
||||
this.isManageClientsMode
|
||||
? `Credencial de ${merged.nome} atualizada com sucesso.`
|
||||
: `Usuario ${merged.nome} atualizado com sucesso.`,
|
||||
'success'
|
||||
);
|
||||
this.showManageUsersFeedback(`Usuario ${merged.nome} atualizado com sucesso.`, 'success');
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.editUserSubmitting = false;
|
||||
|
|
@ -884,17 +793,12 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
if (Array.isArray(apiErrors)) {
|
||||
this.editUserErrors = apiErrors.map((e: any) => ({
|
||||
field: e?.field,
|
||||
message: e?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
|
||||
message: e?.message || 'Erro ao atualizar usuario.',
|
||||
}));
|
||||
} else {
|
||||
this.editUserErrors = [{
|
||||
message: err?.error?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.')
|
||||
}];
|
||||
this.editUserErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }];
|
||||
}
|
||||
this.showManageUsersFeedback(
|
||||
this.editUserErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
|
||||
'error'
|
||||
);
|
||||
this.showManageUsersFeedback(this.editUserErrors[0]?.message || 'Erro ao atualizar usuario.', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -902,13 +806,11 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
async confirmToggleUserStatus(user: any) {
|
||||
const nextActive = user.ativo === false;
|
||||
const actionLabel = nextActive ? 'reativar' : 'inativar';
|
||||
const entity = this.isManageClientsMode ? 'Credencial do Cliente' : 'Usuário';
|
||||
const entityLower = this.isManageClientsMode ? 'credencial do cliente' : 'usuário';
|
||||
const confirmed = await confirmActionModal({
|
||||
title: nextActive ? `Reativar ${entity}` : `Inativar ${entity}`,
|
||||
title: nextActive ? 'Reativar Usuário' : 'Inativar Usuário',
|
||||
message: nextActive
|
||||
? `Deseja reativar ${entityLower} ${user.nome}? O acesso ao sistema será liberado novamente.`
|
||||
: `Deseja inativar ${entityLower} ${user.nome}? O acesso ao sistema ficará bloqueado até reativação.`,
|
||||
? `Deseja reativar o usuário ${user.nome}? Ele voltará a ter acesso ao sistema.`
|
||||
: `Deseja inativar o usuário ${user.nome}? A conta ficará sem acesso até ser reativada.`,
|
||||
confirmLabel: nextActive ? 'Reativar' : 'Inativar',
|
||||
tone: nextActive ? 'neutral' : 'warning',
|
||||
});
|
||||
|
|
@ -922,13 +824,11 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.editUserTarget = { ...this.editUserTarget, ativo: nextActive };
|
||||
this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' });
|
||||
this.editUserErrors = [];
|
||||
this.editUserSuccess = this.isManageClientsMode
|
||||
? `Credencial de ${user.nome} ${nextActive ? 'reativada' : 'inativada'} com sucesso.`
|
||||
: `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`;
|
||||
this.editUserSuccess = `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`;
|
||||
}
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
const message = err?.error?.message || `Erro ao ${actionLabel} ${this.isManageClientsMode ? 'credencial do cliente' : 'usuario'}.`;
|
||||
const message = err?.error?.message || `Erro ao ${actionLabel} usuario.`;
|
||||
if (this.editUserTarget?.id === user.id) {
|
||||
this.editUserSuccess = '';
|
||||
this.editUserErrors = [{ message }];
|
||||
|
|
@ -939,9 +839,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
|
||||
async confirmPermanentDeleteUser(user: any) {
|
||||
if (user?.ativo !== false) {
|
||||
const message = this.isManageClientsMode
|
||||
? 'Inative a credencial antes de excluir permanentemente.'
|
||||
: 'Inative a conta antes de excluir permanentemente.';
|
||||
const message = 'Inative a conta antes de excluir permanentemente.';
|
||||
if (this.editUserTarget?.id === user?.id) {
|
||||
this.editUserSuccess = '';
|
||||
this.editUserErrors = [{ message }];
|
||||
|
|
@ -951,9 +849,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDeletionWithTyping(
|
||||
this.isManageClientsMode ? `a credencial do cliente ${user.nome}` : `o usuário ${user.nome}`
|
||||
);
|
||||
const confirmed = await confirmDeletionWithTyping(`o usuário ${user.nome}`);
|
||||
if (!confirmed) return;
|
||||
|
||||
this.usersService.delete(user.id).subscribe({
|
||||
|
|
@ -966,8 +862,8 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
error: (err: HttpErrorResponse) => {
|
||||
const apiErrors = err?.error?.errors;
|
||||
const message = Array.isArray(apiErrors)
|
||||
? (apiErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.'))
|
||||
: (err?.error?.message || (this.isManageClientsMode ? 'Erro ao excluir credencial do cliente.' : 'Erro ao excluir usuario.'));
|
||||
? (apiErrors[0]?.message || 'Erro ao excluir usuario.')
|
||||
: (err?.error?.message || 'Erro ao excluir usuario.');
|
||||
|
||||
if (this.editUserTarget?.id === user.id) {
|
||||
this.editUserSuccess = '';
|
||||
|
|
@ -1019,30 +915,6 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
this.cancelEditUser();
|
||||
}
|
||||
|
||||
get isManageClientsMode(): boolean {
|
||||
return this.manageMode === 'clients';
|
||||
}
|
||||
|
||||
get manageModalTitle(): string {
|
||||
return this.isManageClientsMode ? 'Credenciais de Clientes' : 'Gestão de Usuários';
|
||||
}
|
||||
|
||||
get manageListTitle(): string {
|
||||
return this.isManageClientsMode ? 'Credenciais de Cliente' : 'Usuários';
|
||||
}
|
||||
|
||||
get manageSearchPlaceholder(): string {
|
||||
return this.isManageClientsMode
|
||||
? 'Buscar por cliente, nome ou email...'
|
||||
: 'Buscar por nome ou email...';
|
||||
}
|
||||
|
||||
get editPermissionOptions() {
|
||||
return this.isManageClientsMode
|
||||
? [{ value: 'cliente', label: 'Cliente' }]
|
||||
: this.permissionOptions;
|
||||
}
|
||||
|
||||
private normalizeField(field?: string | null): string {
|
||||
return (field || '').trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -1054,12 +926,7 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
|
||||
private setEditFormDisabled(disabled: boolean) {
|
||||
if (disabled) this.editUserForm.disable({ emitEvent: false });
|
||||
else {
|
||||
this.editUserForm.enable({ emitEvent: false });
|
||||
if (this.isManageClientsMode) {
|
||||
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
else this.editUserForm.enable({ emitEvent: false });
|
||||
}
|
||||
|
||||
private upsertManageUser(user: any) {
|
||||
|
|
@ -1165,66 +1032,4 @@ export class Header implements AfterViewInit, OnDestroy {
|
|||
private getHeaderElement(): HTMLElement | null {
|
||||
return this.hostElement.nativeElement.querySelector('.app-header');
|
||||
}
|
||||
|
||||
private ensureClientTenantName() {
|
||||
const profile = this.authService.currentUserProfile;
|
||||
const tenantId = String(profile?.tenantId || '').trim();
|
||||
|
||||
if (!tenantId) {
|
||||
this.clientTenantDisplayName = '';
|
||||
this.clientTenantNameTenantId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clientTenantNameTenantId === tenantId && this.clientTenantDisplayName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientTenantNameTenantId = tenantId;
|
||||
this.clientTenantDisplayName = '';
|
||||
|
||||
this.http.get<string[]>(`${this.baseApi}/lines/clients`).subscribe({
|
||||
next: (clients) => {
|
||||
const list = (clients || [])
|
||||
.map((x) => String(x ?? '').trim())
|
||||
.filter((x) => !!x && x.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0);
|
||||
|
||||
this.clientTenantDisplayName = list[0] || this.resolveFallbackClientTenantName();
|
||||
},
|
||||
error: () => {
|
||||
this.clientTenantDisplayName = this.resolveFallbackClientTenantName();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveFallbackClientTenantName(): string {
|
||||
const profileName = String(this.authService.currentUserProfile?.nome || '').trim();
|
||||
if (profileName) return profileName;
|
||||
return 'Cliente';
|
||||
}
|
||||
|
||||
private abbreviateClientTenantName(value: string): string {
|
||||
const name = (value || '').trim();
|
||||
if (!name) return 'Cliente';
|
||||
|
||||
const maxLen = 30;
|
||||
if (name.length <= maxLen) return name;
|
||||
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
return `${parts[0].slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
let candidate = `${first} ${last}`;
|
||||
|
||||
if (candidate.length <= maxLen) return candidate;
|
||||
|
||||
const shortFirst = `${first.slice(0, Math.max(3, maxLen - (last.length + 3)))}.`;
|
||||
candidate = `${shortFirst} ${last}`;
|
||||
if (candidate.length <= maxLen) return candidate;
|
||||
|
||||
return `${name.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { CanActivateFn, Router } from '@angular/router';
|
|||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const sysadminOrGestorGuard: CanActivateFn = () => {
|
||||
export const adminGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
const authService = inject(AuthService);
|
||||
|
|
@ -18,8 +18,8 @@ export const sysadminOrGestorGuard: CanActivateFn = () => {
|
|||
return router.parseUrl('/login');
|
||||
}
|
||||
|
||||
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
|
||||
if (!hasAccess) {
|
||||
const isAdmin = authService.hasRole('admin');
|
||||
if (!isAdmin) {
|
||||
return router.parseUrl('/dashboard');
|
||||
}
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { inject, PLATFORM_ID } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const sysadminOnlyGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
const authService = inject(AuthService);
|
||||
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = authService.token;
|
||||
if (!token) {
|
||||
return router.parseUrl('/login');
|
||||
}
|
||||
|
||||
const isSysAdmin = authService.hasRole('sysadmin');
|
||||
if (!isSysAdmin) {
|
||||
return router.parseUrl('/dashboard');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -36,14 +36,14 @@
|
|||
|
||||
<div class="header-actions d-flex gap-2 justify-content-end">
|
||||
<button
|
||||
*ngIf="isSysAdmin && activeTab === 'chips'"
|
||||
*ngIf="isAdmin && activeTab === 'chips'"
|
||||
class="btn btn-brand btn-sm"
|
||||
(click)="openChipCreate()"
|
||||
>
|
||||
<i class="bi bi-plus-circle me-1"></i> Novo Chip
|
||||
</button>
|
||||
<button
|
||||
*ngIf="isSysAdmin && activeTab === 'controle'"
|
||||
*ngIf="isAdmin && 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="isSysAdmin" class="btn-icon primary" (click)="openChipEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openChipEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openChipDelete(r); $event.stopPropagation()" title="Excluir">
|
||||
<button *ngIf="isAdmin" 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="isSysAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
|
||||
<button *ngIf="isAdmin" 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="isSysAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openControleEdit(r); $event.stopPropagation()" title="Editar">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
|
||||
<button *ngIf="isAdmin" 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;
|
||||
|
||||
isSysAdmin = false;
|
||||
isAdmin = 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.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
this.fetchChips();
|
||||
this.fetchControle();
|
||||
}
|
||||
|
|
@ -236,7 +236,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openChipCreate() {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.chipCreateModel = {
|
||||
id: '',
|
||||
item: null,
|
||||
|
|
@ -278,7 +278,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openChipEdit(row: ChipVirgemListDto) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) 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.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.chipDeleteTarget = row;
|
||||
this.chipDeleteOpen = true;
|
||||
}
|
||||
|
|
@ -498,7 +498,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openControleCreate() {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.controleCreateModel = {
|
||||
id: '',
|
||||
ano: new Date().getFullYear(),
|
||||
|
|
@ -603,7 +603,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openControleEdit(row: ControleRecebidoListDto) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) 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.isSysAdmin) return;
|
||||
if (!this.isAdmin) 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="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
|
||||
<button *ngIf="isAdmin" 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="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>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -231,31 +231,34 @@
|
|||
<div class="edit-sections">
|
||||
<details open class="detail-box">
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com Reserva</span>
|
||||
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com GERAL</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>Linha (RESERVA)</label>
|
||||
<label>Cliente (GERAL)</label>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="clientsFromGeral"
|
||||
[(ngModel)]="createModel.selectedClient"
|
||||
(ngModelChange)="onCreateClientChange()"
|
||||
[disabled]="createClientsLoading"
|
||||
></app-select>
|
||||
</div>
|
||||
<div class="form-field span-2">
|
||||
<label>Linha (GERAL)</label>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="lineOptionsCreate"
|
||||
labelKey="label"
|
||||
valueKey="id"
|
||||
[searchable]="true"
|
||||
searchPlaceholder="Pesquisar linha da reserva..."
|
||||
[(ngModel)]="createModel.mobileLineId"
|
||||
(ngModelChange)="onCreateLineChange()"
|
||||
[disabled]="createLinesLoading"
|
||||
placeholder="Selecione uma linha da Reserva..."
|
||||
[disabled]="createLinesLoading || !createModel.selectedClient"
|
||||
></app-select>
|
||||
<small class="field-hint" *ngIf="createLinesLoading">Carregando linhas da Reserva...</small>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Total Franquia Line</label>
|
||||
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(createFranquiaLineTotal)" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -288,10 +291,7 @@
|
|||
<label>Razão Social</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="createModel.razaoSocial" />
|
||||
</div>
|
||||
<div class="form-field field-line">
|
||||
<label>Linha</label>
|
||||
<input class="form-control form-control-sm bg-light" [value]="createModel.linha || ''" readonly />
|
||||
</div>
|
||||
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="createModel.linha" /></div>
|
||||
<div class="form-field field-item field-auto">
|
||||
<label>Item (Automático)</label>
|
||||
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="createModel.item" readonly title="Gerado automaticamente pelo sistema" />
|
||||
|
|
@ -357,26 +357,7 @@
|
|||
<label>Razão Social</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.razaoSocial" />
|
||||
</div>
|
||||
<div class="form-field field-line">
|
||||
<label>Linha (Reserva)</label>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="editLineOptions"
|
||||
labelKey="label"
|
||||
valueKey="id"
|
||||
[searchable]="true"
|
||||
searchPlaceholder="Pesquisar linha da reserva..."
|
||||
[(ngModel)]="editSelectedLineId"
|
||||
(ngModelChange)="onEditLineChange()"
|
||||
[disabled]="createLinesLoading"
|
||||
placeholder="Selecione uma linha da Reserva..."
|
||||
></app-select>
|
||||
</div>
|
||||
<div class="form-field field-auto">
|
||||
<label>Total Franquia Line</label>
|
||||
<input class="form-control form-control-sm bg-light" [value]="formatFranquiaLine(editFranquiaLineTotal)" readonly />
|
||||
</div>
|
||||
<div class="form-field field-line"><label>Linha</label><input class="form-control form-control-sm" inputmode="numeric" [(ngModel)]="editModel.linha" /></div>
|
||||
<div class="form-field field-item field-auto">
|
||||
<label>Item (Automático)</label>
|
||||
<input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" readonly title="Gerado automaticamente pelo sistema" />
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ interface LineOptionDto {
|
|||
item: number;
|
||||
linha: string | null;
|
||||
usuario: string | null;
|
||||
franquiaLine?: number | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
|
|
@ -95,18 +94,16 @@ export class DadosUsuarios implements OnInit {
|
|||
createSaving = false;
|
||||
createModel: any = null;
|
||||
createDateNascimento = '';
|
||||
createFranquiaLineTotal = 0;
|
||||
editFranquiaLineTotal = 0;
|
||||
editSelectedLineId = '';
|
||||
editLineOptions: LineOptionDto[] = [];
|
||||
clientsFromGeral: string[] = [];
|
||||
lineOptionsCreate: LineOptionDto[] = [];
|
||||
readonly tipoPessoaOptions: SimpleOption[] = [
|
||||
{ label: 'Pessoa Física', value: 'PF' },
|
||||
{ label: 'Pessoa Jurídica', value: 'PJ' },
|
||||
];
|
||||
createClientsLoading = false;
|
||||
createLinesLoading = false;
|
||||
|
||||
isSysAdmin = false;
|
||||
isAdmin = false;
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: 'success' | 'danger' = 'success';
|
||||
|
|
@ -120,7 +117,7 @@ export class DadosUsuarios implements OnInit {
|
|||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
this.fetch(1);
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +283,7 @@ export class DadosUsuarios implements OnInit {
|
|||
closeDetails() { this.detailsOpen = false; }
|
||||
|
||||
openEdit(row: UserDataRow) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.service.getById(row.id).subscribe({
|
||||
next: (fullData: UserDataRow) => {
|
||||
this.editingId = fullData.id;
|
||||
|
|
@ -298,11 +295,7 @@ export class DadosUsuarios implements OnInit {
|
|||
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
|
||||
};
|
||||
this.editDateNascimento = this.toDateInput(fullData.dataNascimento);
|
||||
this.editFranquiaLineTotal = 0;
|
||||
this.editSelectedLineId = '';
|
||||
this.editLineOptions = [];
|
||||
this.editOpen = true;
|
||||
this.loadReserveLinesForSelects();
|
||||
},
|
||||
error: () => this.showToast('Erro ao abrir edição', 'danger')
|
||||
});
|
||||
|
|
@ -314,9 +307,6 @@ export class DadosUsuarios implements OnInit {
|
|||
this.editModel = null;
|
||||
this.editDateNascimento = '';
|
||||
this.editingId = null;
|
||||
this.editSelectedLineId = '';
|
||||
this.editLineOptions = [];
|
||||
this.editFranquiaLineTotal = 0;
|
||||
}
|
||||
|
||||
onEditTipoChange() {
|
||||
|
|
@ -376,17 +366,16 @@ export class DadosUsuarios implements OnInit {
|
|||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.loadReserveLinesForSelects();
|
||||
this.preloadGeralClients();
|
||||
}
|
||||
|
||||
closeCreate() {
|
||||
this.createOpen = false;
|
||||
this.createSaving = false;
|
||||
this.createModel = null;
|
||||
this.createFranquiaLineTotal = 0;
|
||||
}
|
||||
|
||||
private resetCreateModel() {
|
||||
|
|
@ -408,9 +397,33 @@ export class DadosUsuarios implements OnInit {
|
|||
telefoneFixo: ''
|
||||
};
|
||||
this.createDateNascimento = '';
|
||||
this.createFranquiaLineTotal = 0;
|
||||
this.lineOptionsCreate = [];
|
||||
this.createLinesLoading = false;
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
|
||||
private preloadGeralClients() {
|
||||
this.createClientsLoading = true;
|
||||
this.linesService.getClients().subscribe({
|
||||
next: (list) => {
|
||||
this.clientsFromGeral = list ?? [];
|
||||
this.createClientsLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.clientsFromGeral = [];
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCreateClientChange() {
|
||||
const c = (this.createModel?.selectedClient ?? '').trim();
|
||||
this.createModel.mobileLineId = '';
|
||||
this.createModel.linha = '';
|
||||
this.createModel.cliente = c;
|
||||
this.lineOptionsCreate = [];
|
||||
|
||||
if (c) this.loadLinesForClient(c);
|
||||
}
|
||||
|
||||
onCreateTipoChange() {
|
||||
|
|
@ -425,9 +438,12 @@ export class DadosUsuarios implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
private loadReserveLinesForSelects(onDone?: () => void) {
|
||||
private loadLinesForClient(cliente: string) {
|
||||
const c = (cliente ?? '').trim();
|
||||
if (!c) return;
|
||||
|
||||
this.createLinesLoading = true;
|
||||
this.linesService.getLinesByClient('RESERVA').subscribe({
|
||||
this.linesService.getLinesByClient(c).subscribe({
|
||||
next: (items: any[]) => {
|
||||
const mapped: LineOptionDto[] = (items ?? [])
|
||||
.filter(x => !!String(x?.id ?? '').trim())
|
||||
|
|
@ -441,16 +457,12 @@ export class DadosUsuarios implements OnInit {
|
|||
.filter(x => !!String(x.linha ?? '').trim());
|
||||
|
||||
this.lineOptionsCreate = mapped;
|
||||
if (this.editModel) this.syncEditLineOptions();
|
||||
this.createLinesLoading = false;
|
||||
onDone?.();
|
||||
},
|
||||
error: () => {
|
||||
this.lineOptionsCreate = [];
|
||||
this.editLineOptions = [];
|
||||
this.createLinesLoading = false;
|
||||
this.showToast('Erro ao carregar linhas da Reserva.', 'danger');
|
||||
onDone?.();
|
||||
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -465,56 +477,13 @@ export class DadosUsuarios implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
onEditLineChange() {
|
||||
const id = String(this.editSelectedLineId ?? '').trim();
|
||||
if (!id || id === '__CURRENT__') return;
|
||||
this.linesService.getById(id).subscribe({
|
||||
next: (d: MobileLineDetail) => this.applyLineDetailToEdit(d),
|
||||
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
|
||||
});
|
||||
}
|
||||
|
||||
private syncEditLineOptions() {
|
||||
if (!this.editModel) {
|
||||
this.editLineOptions = [];
|
||||
this.editSelectedLineId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLine = String(this.editModel.linha ?? '').trim();
|
||||
const fromReserva = this.lineOptionsCreate.find((x) => String(x.linha ?? '').trim() === currentLine);
|
||||
const options = [...this.lineOptionsCreate];
|
||||
|
||||
if (currentLine && !fromReserva) {
|
||||
options.unshift({
|
||||
id: '__CURRENT__',
|
||||
item: Number(this.editModel.item ?? 0),
|
||||
linha: currentLine,
|
||||
usuario: this.editModel.cliente ?? null,
|
||||
label: `Atual • ${currentLine}`
|
||||
});
|
||||
}
|
||||
|
||||
this.editLineOptions = options;
|
||||
this.editSelectedLineId = fromReserva?.id ?? (currentLine ? '__CURRENT__' : '');
|
||||
if (fromReserva?.id) {
|
||||
this.onEditLineChange();
|
||||
}
|
||||
}
|
||||
|
||||
private applyLineDetailToCreate(d: MobileLineDetail) {
|
||||
this.createModel.linha = d.linha ?? '';
|
||||
this.createFranquiaLineTotal = this.toNullableNumber(d.franquiaLine) ?? 0;
|
||||
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
|
||||
if (!String(this.createModel.item ?? '').trim() && d.item) {
|
||||
this.createModel.item = String(d.item);
|
||||
}
|
||||
|
||||
const lineClient = String(d.cliente ?? '').trim();
|
||||
const isReserva = lineClient.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
|
||||
if (!isReserva && lineClient) {
|
||||
this.createModel.cliente = lineClient;
|
||||
}
|
||||
|
||||
if ((this.createModel.tipoPessoa ?? '').toUpperCase() === 'PJ') {
|
||||
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
|
||||
} else {
|
||||
|
|
@ -522,15 +491,6 @@ export class DadosUsuarios implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
private applyLineDetailToEdit(d: MobileLineDetail) {
|
||||
if (!this.editModel) return;
|
||||
this.editModel.linha = d.linha ?? this.editModel.linha;
|
||||
this.editFranquiaLineTotal = this.toNullableNumber(d.franquiaLine) ?? 0;
|
||||
if (!String(this.editModel.item ?? '').trim() && d.item) {
|
||||
this.editModel.item = d.item;
|
||||
}
|
||||
}
|
||||
|
||||
saveCreate() {
|
||||
if (!this.createModel) return;
|
||||
this.createSaving = true;
|
||||
|
|
@ -572,7 +532,7 @@ export class DadosUsuarios implements OnInit {
|
|||
}
|
||||
|
||||
openDelete(row: UserDataRow) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = row;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
|
@ -624,11 +584,6 @@ export class DadosUsuarios implements OnInit {
|
|||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
formatFranquiaLine(value: any): string {
|
||||
const n = this.toNullableNumber(value) ?? 0;
|
||||
return `${n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} GB`;
|
||||
}
|
||||
|
||||
private normalizeTipo(row: UserDataRow | null | undefined): 'PF' | 'PJ' {
|
||||
const t = (row?.tipoPessoa ?? '').toString().trim().toUpperCase();
|
||||
if (t === 'PJ') return 'PJ';
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@
|
|||
<div class="page-head fade-in-up">
|
||||
<div class="head-content">
|
||||
<div class="badge-pill">
|
||||
<i class="bi bi-grid-1x2-fill"></i> {{ isCliente ? 'Visão Cliente' : 'Visão Geral' }}
|
||||
<i class="bi bi-grid-1x2-fill"></i> Visão Geral
|
||||
</div>
|
||||
<h1 class="page-title">{{ isCliente ? 'Dashboard do Cliente' : 'Dashboard de Gestão de Linhas' }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{{ isCliente ? 'Acompanhe suas linhas em tempo real com foco em operação e disponibilidade.' : 'Painel operacional com foco em status, cobertura e histórico da base.' }}
|
||||
</p>
|
||||
<h1 class="page-title">Dashboard de Gestão de Linhas</h1>
|
||||
<p class="page-subtitle">Painel operacional com foco em status, cobertura e histórico da base.</p>
|
||||
</div>
|
||||
|
||||
<div class="head-actions">
|
||||
|
|
@ -29,7 +27,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'" *ngIf="!isCliente || clientOverview.hasData">
|
||||
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'">
|
||||
<div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey">
|
||||
<div class="hero-icon">
|
||||
<i [class]="k.icon"></i>
|
||||
|
|
@ -42,7 +40,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isCliente; else clienteDashboard">
|
||||
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
|
||||
<h2>Página Geral</h2>
|
||||
<p>Distribuição e saúde atual da base de linhas.</p>
|
||||
|
|
@ -325,114 +322,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #clienteDashboard>
|
||||
<ng-container *ngIf="clientOverview.hasData; else clienteSemDados">
|
||||
<div class="context-title fade-in-up" [style.animation-delay]="'180ms'">
|
||||
<h2>Monitoramento da Sua Base</h2>
|
||||
<p>Visão operacional das suas linhas para acompanhar uso, status e disponibilidade.</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'220ms'">
|
||||
<div class="section-top-row">
|
||||
<div class="card-modern card-status">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-icon brand"><i class="bi bi-pie-chart-fill"></i></div>
|
||||
<div class="header-text">
|
||||
<h3>Status das Linhas</h3>
|
||||
<p>Distribuição atual da sua base</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body-split">
|
||||
<div class="chart-wrapper-pie">
|
||||
<canvas #chartStatusPie></canvas>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span class="dot d-active"></span>
|
||||
<span class="lbl">Ativas</span>
|
||||
<span class="val">{{ statusResumo.ativos | number:'1.0-0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-modern">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-icon blue"><i class="bi bi-wifi"></i></div>
|
||||
<div class="header-text">
|
||||
<h3>Faixa de Franquia Line</h3>
|
||||
<p>Quantidade de linhas por faixa de franquia contratada</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper-bar compact-half">
|
||||
<canvas #chartLinhasPorFranquia></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'260ms'">
|
||||
<div class="card-modern full-width">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-text">
|
||||
<h3>Top Planos (Qtd. Linhas)</h3>
|
||||
<p>Planos com maior volume na sua operação</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper-bar compact">
|
||||
<canvas #chartResumoTopPlanos></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-halves mt-3 client-secondary-grid">
|
||||
<div class="card-modern">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-text">
|
||||
<h3>Top Usuários (Qtd. Linhas)</h3>
|
||||
<p>Apenas usuários de fato (sem bloqueados/aguardando)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper-bar compact">
|
||||
<canvas #chartResumoTopClientes></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-modern">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-icon brand"><i class="bi bi-sim"></i></div>
|
||||
<div class="header-text">
|
||||
<h3>Tipo de Chip</h3>
|
||||
<p>Distribuição entre e-SIM, SIMCARD e outros</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper-pie">
|
||||
<canvas #chartTipoChip></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #clienteSemDados>
|
||||
<div class="dashboard-section fade-in-up" [style.animation-delay]="'180ms'">
|
||||
<div class="card-modern full-width">
|
||||
<div class="card-header-clean">
|
||||
<div class="header-icon warning"><i class="bi bi-info-circle-fill"></i></div>
|
||||
<div class="header-text">
|
||||
<h3>Sem dados para exibição</h3>
|
||||
<p>Não encontramos linhas vinculadas ao seu acesso no momento.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body-grid">
|
||||
<p class="mb-0 text-muted">
|
||||
Assim que a base deste cliente estiver disponível na página Geral, os KPIs e gráficos serão atualizados automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -163,10 +163,6 @@
|
|||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
|
|
@ -206,11 +202,11 @@
|
|||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hero-value {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
ResumoResponse,
|
||||
LineTotal,
|
||||
} from '../../services/resumo.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
|
||||
type KpiCard = {
|
||||
|
|
@ -111,7 +110,6 @@ type InsightsChartSeries = {
|
|||
type InsightsKpisVivo = {
|
||||
qtdLinhas?: number | null;
|
||||
totalFranquiaGb?: number | null;
|
||||
totalFranquiaLine?: number | null;
|
||||
totalBaseMensal?: number | null;
|
||||
totalAdicionaisMensal?: number | null;
|
||||
totalGeralMensal?: number | null;
|
||||
|
|
@ -156,13 +154,6 @@ type DashboardGeralInsightsDto = {
|
|||
};
|
||||
|
||||
type DashboardLineListItemDto = {
|
||||
linha?: string | null;
|
||||
cliente?: string | null;
|
||||
usuario?: string | null;
|
||||
skil?: string | null;
|
||||
planoContrato?: string | null;
|
||||
status?: string | null;
|
||||
franquiaLine?: number | null;
|
||||
gestaoVozDados?: number | null;
|
||||
skeelo?: number | null;
|
||||
vivoNewsPlus?: number | null;
|
||||
|
|
@ -172,6 +163,13 @@ type DashboardLineListItemDto = {
|
|||
tipoDeChip?: string | null;
|
||||
};
|
||||
|
||||
type DashboardLinesPageDto = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: DashboardLineListItemDto[];
|
||||
};
|
||||
|
||||
type ResumoTopCliente = {
|
||||
cliente: string;
|
||||
linhas: number;
|
||||
|
|
@ -194,18 +192,6 @@ type ResumoDiferencaPjPf = {
|
|||
totalLinhas: number | null;
|
||||
};
|
||||
|
||||
type ClientDashboardOverview = {
|
||||
hasData: boolean;
|
||||
totalLinhas: number;
|
||||
ativas: number;
|
||||
bloqueadas: number;
|
||||
reservas: number;
|
||||
franquiaLineTotalGb: number;
|
||||
planosContratados: number;
|
||||
usuariosComLinha: number;
|
||||
outrosStatus: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
|
|
@ -232,7 +218,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
loading = true;
|
||||
errorMsg: string | null = null;
|
||||
isCliente = false;
|
||||
|
||||
kpis: KpiCard[] = [];
|
||||
|
||||
|
|
@ -255,7 +240,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
statusResumo = {
|
||||
total: 0,
|
||||
ativos: 0,
|
||||
bloqueadas: 0,
|
||||
perdaRoubo: 0,
|
||||
bloq120: 0,
|
||||
reservas: 0,
|
||||
|
|
@ -299,17 +283,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
resumo: ResumoResponse | null = null;
|
||||
resumoTopN = 5;
|
||||
resumoTopOptions = [5, 10, 15];
|
||||
clientOverview: ClientDashboardOverview = {
|
||||
hasData: false,
|
||||
totalLinhas: 0,
|
||||
ativas: 0,
|
||||
bloqueadas: 0,
|
||||
reservas: 0,
|
||||
franquiaLineTotalGb: 0,
|
||||
planosContratados: 0,
|
||||
usuariosComLinha: 0,
|
||||
outrosStatus: 0,
|
||||
};
|
||||
|
||||
// Resumo Derived Data
|
||||
resumoTopClientes: ResumoTopCliente[] = [];
|
||||
|
|
@ -358,7 +331,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
constructor(
|
||||
private http: HttpClient,
|
||||
private resumoService: ResumoService,
|
||||
private authService: AuthService,
|
||||
@Inject(PLATFORM_ID) private platformId: object
|
||||
) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
|
|
@ -368,15 +340,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
ngOnInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
const isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
const isGestor = this.authService.hasRole('gestor');
|
||||
this.isCliente = !(isSysAdmin || isGestor);
|
||||
|
||||
if (this.isCliente) {
|
||||
this.loadClientDashboardData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadDashboard();
|
||||
this.loadInsights();
|
||||
this.loadResumoExecutive();
|
||||
|
|
@ -416,245 +379,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
private async loadClientDashboardData() {
|
||||
this.loading = true;
|
||||
this.errorMsg = null;
|
||||
this.dataReady = false;
|
||||
this.resumoLoading = true;
|
||||
this.resumoError = null;
|
||||
this.resumoReady = false;
|
||||
|
||||
try {
|
||||
const [operacionais, reservas] = await Promise.all([
|
||||
this.fetchAllDashboardLines(false),
|
||||
this.fetchAllDashboardLines(true),
|
||||
]);
|
||||
const allLines = [...operacionais, ...reservas];
|
||||
this.applyClientLineAggregates(allLines);
|
||||
|
||||
this.loading = false;
|
||||
this.resumoLoading = false;
|
||||
this.dataReady = true;
|
||||
this.resumoReady = true;
|
||||
this.tryBuildCharts();
|
||||
this.tryBuildResumoCharts();
|
||||
} catch (error) {
|
||||
this.loading = false;
|
||||
this.resumoLoading = false;
|
||||
this.resumoReady = false;
|
||||
this.dataReady = false;
|
||||
this.errorMsg = this.isNetworkError(error)
|
||||
? 'Falha ao carregar o Dashboard. Verifique a conexão.'
|
||||
: 'Falha ao carregar os dados do cliente.';
|
||||
this.clearClientDashboardState();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAllDashboardLines(onlyReserva: boolean): Promise<DashboardLineListItemDto[]> {
|
||||
const pageSize = 500;
|
||||
let page = 1;
|
||||
const all: DashboardLineListItemDto[] = [];
|
||||
|
||||
while (true) {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(page))
|
||||
.set('pageSize', String(pageSize));
|
||||
|
||||
if (onlyReserva) {
|
||||
params = params.set('skil', 'RESERVA');
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(this.http.get<any>(`${this.baseApi}/lines`, { params }));
|
||||
const itemsRaw = this.readNode(response, 'items', 'Items');
|
||||
const items = Array.isArray(itemsRaw) ? (itemsRaw as DashboardLineListItemDto[]) : [];
|
||||
all.push(...items);
|
||||
|
||||
const total = this.toNumberOrNull(this.readNode(response, 'total', 'Total'));
|
||||
if (!items.length) break;
|
||||
if (total !== null && all.length >= total) break;
|
||||
if (items.length < pageSize) break;
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
private applyClientLineAggregates(
|
||||
allLines: DashboardLineListItemDto[]
|
||||
): void {
|
||||
const planMap = new Map<string, number>();
|
||||
const userMap = new Map<string, number>();
|
||||
const franquiaBandMap = new Map<string, number>();
|
||||
|
||||
let totalLinhas = 0;
|
||||
let ativas = 0;
|
||||
let bloqueadas = 0;
|
||||
let reservas = 0;
|
||||
let outrosStatus = 0;
|
||||
let franquiaLineTotalGb = 0;
|
||||
let eSim = 0;
|
||||
let simCard = 0;
|
||||
let outrosChip = 0;
|
||||
|
||||
for (const line of allLines) {
|
||||
totalLinhas += 1;
|
||||
const isReserva = this.isReservaLine(line);
|
||||
const status = this.normalizeSeriesKey(this.readLineString(line, 'status', 'Status'));
|
||||
const planoContrato = this.readLineString(line, 'planoContrato', 'PlanoContrato').trim();
|
||||
const usuario = this.readLineString(line, 'usuario', 'Usuario').trim();
|
||||
const usuarioKey = this.normalizeSeriesKey(usuario);
|
||||
const franquiaLine = this.readLineNumber(line, 'franquiaLine', 'FranquiaLine');
|
||||
franquiaLineTotalGb += franquiaLine > 0 ? franquiaLine : 0;
|
||||
|
||||
if (isReserva) {
|
||||
reservas += 1;
|
||||
} else if (status.includes('ATIV')) {
|
||||
ativas += 1;
|
||||
} else if (
|
||||
status.includes('BLOQUE') ||
|
||||
status.includes('PERDA') ||
|
||||
status.includes('ROUBO') ||
|
||||
status.includes('SUSPEN') ||
|
||||
status.includes('CANCEL')
|
||||
) {
|
||||
bloqueadas += 1;
|
||||
} else {
|
||||
outrosStatus += 1;
|
||||
}
|
||||
|
||||
if (!isReserva) {
|
||||
const planoKey = planoContrato || 'Sem plano';
|
||||
planMap.set(planoKey, (planMap.get(planoKey) ?? 0) + 1);
|
||||
|
||||
if (this.shouldIncludeTopUsuario(usuarioKey, status)) {
|
||||
userMap.set(usuario, (userMap.get(usuario) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const faixa = this.resolveFranquiaLineBand(franquiaLine);
|
||||
franquiaBandMap.set(faixa, (franquiaBandMap.get(faixa) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const chipType = this.normalizeChipType(this.readLineString(line, 'tipoDeChip', 'TipoDeChip'));
|
||||
if (chipType === 'ESIM') {
|
||||
eSim += 1;
|
||||
} else if (chipType === 'SIMCARD') {
|
||||
simCard += 1;
|
||||
} else {
|
||||
outrosChip += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topPlanos = Array.from(planMap.entries())
|
||||
.map(([plano, linhas]) => ({ plano, linhas }))
|
||||
.sort((a, b) => b.linhas - a.linhas || a.plano.localeCompare(b.plano, 'pt-BR'))
|
||||
.slice(0, this.resumoTopN);
|
||||
|
||||
const topUsuarios = Array.from(userMap.entries())
|
||||
.map(([cliente, linhas]) => ({ cliente, linhas }))
|
||||
.sort((a, b) => b.linhas - a.linhas || a.cliente.localeCompare(b.cliente, 'pt-BR'))
|
||||
.slice(0, this.resumoTopN);
|
||||
|
||||
const franquiaOrder = ['Sem franquia', 'Até 10 GB', '10 a 20 GB', '20 a 50 GB', 'Acima de 50 GB'];
|
||||
const franquiaLabels = franquiaOrder.filter((label) => (franquiaBandMap.get(label) ?? 0) > 0);
|
||||
this.franquiaLabels = franquiaLabels.length ? franquiaLabels : franquiaOrder;
|
||||
this.franquiaValues = this.franquiaLabels.map((label) => franquiaBandMap.get(label) ?? 0);
|
||||
|
||||
this.tipoChipLabels = ['e-SIM', 'SIMCARD', 'Outros'];
|
||||
this.tipoChipValues = [eSim, simCard, outrosChip];
|
||||
this.travelLabels = [];
|
||||
this.travelValues = [];
|
||||
this.adicionaisLabels = [];
|
||||
this.adicionaisValues = [];
|
||||
this.adicionaisTotals = [];
|
||||
this.insights = null;
|
||||
this.rebuildAdicionaisComparativo(null);
|
||||
|
||||
this.statusResumo = {
|
||||
total: totalLinhas,
|
||||
ativos: ativas,
|
||||
bloqueadas,
|
||||
perdaRoubo: 0,
|
||||
bloq120: 0,
|
||||
reservas,
|
||||
outras: outrosStatus,
|
||||
};
|
||||
|
||||
this.clientOverview = {
|
||||
hasData: totalLinhas > 0,
|
||||
totalLinhas,
|
||||
ativas,
|
||||
bloqueadas,
|
||||
reservas,
|
||||
franquiaLineTotalGb,
|
||||
planosContratados: planMap.size,
|
||||
usuariosComLinha: userMap.size,
|
||||
outrosStatus,
|
||||
};
|
||||
|
||||
this.resumoTopPlanos = topPlanos;
|
||||
this.resumoPlanosLabels = topPlanos.map((x) => x.plano);
|
||||
this.resumoPlanosValues = topPlanos.map((x) => x.linhas);
|
||||
|
||||
this.resumoTopClientes = topUsuarios;
|
||||
this.resumoClientesLabels = topUsuarios.map((x) => x.cliente);
|
||||
this.resumoClientesValues = topUsuarios.map((x) => x.linhas);
|
||||
|
||||
this.resumoTopReserva = [];
|
||||
this.resumoReservaLabels = [];
|
||||
this.resumoReservaValues = [];
|
||||
|
||||
this.resumoPfPjLabels = [];
|
||||
this.resumoPfPjValues = [];
|
||||
this.resumoDiferencaPjPf = {
|
||||
pfLinhas: null,
|
||||
pjLinhas: null,
|
||||
totalLinhas: null,
|
||||
};
|
||||
|
||||
this.resumo = null;
|
||||
this.rebuildPrimaryKpis();
|
||||
}
|
||||
|
||||
private clearClientDashboardState() {
|
||||
this.clientOverview = {
|
||||
hasData: false,
|
||||
totalLinhas: 0,
|
||||
ativas: 0,
|
||||
bloqueadas: 0,
|
||||
reservas: 0,
|
||||
franquiaLineTotalGb: 0,
|
||||
planosContratados: 0,
|
||||
usuariosComLinha: 0,
|
||||
outrosStatus: 0,
|
||||
};
|
||||
this.statusResumo = {
|
||||
total: 0,
|
||||
ativos: 0,
|
||||
bloqueadas: 0,
|
||||
perdaRoubo: 0,
|
||||
bloq120: 0,
|
||||
reservas: 0,
|
||||
outras: 0,
|
||||
};
|
||||
this.franquiaLabels = [];
|
||||
this.franquiaValues = [];
|
||||
this.tipoChipLabels = [];
|
||||
this.tipoChipValues = [];
|
||||
this.resumoTopClientes = [];
|
||||
this.resumoTopPlanos = [];
|
||||
this.resumoTopReserva = [];
|
||||
this.resumoPlanosLabels = [];
|
||||
this.resumoPlanosValues = [];
|
||||
this.resumoClientesLabels = [];
|
||||
this.resumoClientesValues = [];
|
||||
this.resumoReservaLabels = [];
|
||||
this.resumoReservaValues = [];
|
||||
this.rebuildPrimaryKpis();
|
||||
this.destroyCharts();
|
||||
this.destroyResumoCharts();
|
||||
}
|
||||
|
||||
private isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
return error.status === 0;
|
||||
|
|
@ -714,10 +438,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onResumoTopNChange() {
|
||||
if (this.isCliente) {
|
||||
void this.loadClientDashboardData();
|
||||
return;
|
||||
}
|
||||
this.buildResumoDerived();
|
||||
this.tryBuildResumoCharts();
|
||||
}
|
||||
|
|
@ -746,7 +466,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.statusResumo = {
|
||||
total: k.totalLinhas ?? 0,
|
||||
ativos: k.ativos ?? 0,
|
||||
bloqueadas: k.bloqueados ?? 0,
|
||||
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
|
||||
bloq120: k.bloqueados120Dias ?? 0,
|
||||
reservas: k.reservas ?? 0,
|
||||
|
|
@ -806,7 +525,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
vivo: {
|
||||
qtdLinhas: this.toNumberOrNull(this.readNode(vivoRaw, 'qtdLinhas', 'QtdLinhas')),
|
||||
totalFranquiaGb: this.toNumberOrNull(this.readNode(vivoRaw, 'totalFranquiaGb', 'TotalFranquiaGb')),
|
||||
totalFranquiaLine: this.toNumberOrNull(this.readNode(vivoRaw, 'totalFranquiaLine', 'TotalFranquiaLine')),
|
||||
totalBaseMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalBaseMensal', 'TotalBaseMensal')),
|
||||
totalAdicionaisMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalAdicionaisMensal', 'TotalAdicionaisMensal')),
|
||||
totalGeralMensal: this.toNumberOrNull(this.readNode(vivoRaw, 'totalGeralMensal', 'TotalGeralMensal')),
|
||||
|
|
@ -1054,7 +772,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
private async loadFallbackFromLinesIfNeeded(force = false): Promise<void> {
|
||||
if (this.isCliente) return;
|
||||
if (!isPlatformBrowser(this.platformId) || this.fallbackInsightsLoading) return;
|
||||
|
||||
const syncIndex = this.adicionaisLabels.findIndex(
|
||||
|
|
@ -1173,58 +890,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
return '';
|
||||
}
|
||||
|
||||
private isReservaLine(line: DashboardLineListItemDto): boolean {
|
||||
const cliente = this.normalizeSeriesKey(this.readLineString(line, 'cliente', 'Cliente'));
|
||||
const usuario = this.normalizeSeriesKey(this.readLineString(line, 'usuario', 'Usuario'));
|
||||
const skil = this.normalizeSeriesKey(this.readLineString(line, 'skil', 'Skil'));
|
||||
return cliente === 'RESERVA' || usuario === 'RESERVA' || skil === 'RESERVA';
|
||||
}
|
||||
|
||||
private shouldIncludeTopUsuario(usuarioKey: string, statusKey: string): boolean {
|
||||
if (!usuarioKey) return false;
|
||||
|
||||
const invalidUserTokens = [
|
||||
'SEMUSUARIO',
|
||||
'AGUARDANDOUSUARIO',
|
||||
'AGUARDANDO',
|
||||
'BLOQUEAR',
|
||||
'BLOQUEAD',
|
||||
'BLOQUEADO',
|
||||
'RESERVA',
|
||||
'NAOATRIBUIDO',
|
||||
'PENDENTE',
|
||||
];
|
||||
if (invalidUserTokens.some((token) => usuarioKey.includes(token))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockedStatusTokens = ['BLOQUE', 'PERDA', 'ROUBO', 'SUSPEN', 'CANCEL', 'AGUARD'];
|
||||
return !blockedStatusTokens.some((token) => statusKey.includes(token));
|
||||
}
|
||||
|
||||
private resolveFranquiaLineBand(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return 'Sem franquia';
|
||||
if (value < 10) return 'Até 10 GB';
|
||||
if (value < 20) return '10 a 20 GB';
|
||||
if (value < 50) return '20 a 50 GB';
|
||||
return 'Acima de 50 GB';
|
||||
}
|
||||
|
||||
private extractDddFromLine(value: string | null | undefined): string | null {
|
||||
const digits = (value ?? '').replace(/\D/g, '');
|
||||
if (!digits) return null;
|
||||
|
||||
if (digits.startsWith('55') && digits.length >= 12) {
|
||||
return digits.slice(2, 4);
|
||||
}
|
||||
|
||||
if (digits.length >= 10) {
|
||||
return digits.slice(0, 2);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private destroyInsightsCharts() {
|
||||
try { this.chartFranquia?.destroy(); } catch {}
|
||||
try { this.chartAdicionais?.destroy(); } catch {}
|
||||
|
|
@ -1247,43 +912,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
private rebuildPrimaryKpis() {
|
||||
if (this.isCliente) {
|
||||
const overview = this.clientOverview;
|
||||
const cards: KpiCard[] = [
|
||||
{
|
||||
key: 'linhas_ativas',
|
||||
title: 'Linhas Ativas',
|
||||
value: this.formatInt(overview.ativas),
|
||||
icon: 'bi bi-check2-circle',
|
||||
hint: 'Status ativo',
|
||||
},
|
||||
{
|
||||
key: 'franquia_line_total',
|
||||
title: 'Franquia Line Total',
|
||||
value: this.formatDataAllowance(overview.franquiaLineTotalGb),
|
||||
icon: 'bi bi-wifi',
|
||||
hint: 'Franquia contratada',
|
||||
},
|
||||
{
|
||||
key: 'planos_contratados',
|
||||
title: 'Planos Contratados',
|
||||
value: this.formatInt(overview.planosContratados),
|
||||
icon: 'bi bi-diagram-3-fill',
|
||||
hint: 'Planos ativos na base',
|
||||
},
|
||||
{
|
||||
key: 'usuarios_com_linha',
|
||||
title: 'Usuários com Linha',
|
||||
value: this.formatInt(overview.usuariosComLinha),
|
||||
icon: 'bi bi-people-fill',
|
||||
hint: 'Usuários vinculados',
|
||||
},
|
||||
];
|
||||
|
||||
this.kpis = cards;
|
||||
return;
|
||||
}
|
||||
|
||||
const cards: KpiCard[] = [];
|
||||
const used = new Set<string>();
|
||||
const add = (key: string, title: string, value: string, icon: string, hint?: string) => {
|
||||
|
|
@ -1299,33 +927,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
add('linhas_ativas', 'Linhas Ativas', this.formatInt(dashboard.ativos), 'bi bi-check2-circle', 'Status ativo');
|
||||
add('linhas_bloqueadas', 'Linhas Bloqueadas', this.formatInt(dashboard.bloqueados), 'bi bi-slash-circle', 'Todos os bloqueios');
|
||||
add('linhas_reserva', 'Linhas em Reserva', this.formatInt(dashboard.reservas), 'bi bi-inboxes-fill', 'Base de reserva');
|
||||
const franquiaVivoTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaGb)
|
||||
?? this.toNumberOrNull(this.resumo?.vivoLineTotals?.franquiaTotal)
|
||||
?? (this.resumo?.vivoLineResumos ?? []).reduce(
|
||||
(acc, row) => acc + (this.toNumberOrNull(row?.franquiaTotal) ?? 0),
|
||||
0
|
||||
);
|
||||
if (insights) {
|
||||
add(
|
||||
'franquia_vivo_total',
|
||||
'Total Franquia Vivo',
|
||||
this.formatDataAllowance(franquiaVivoTotal),
|
||||
this.formatGb(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0),
|
||||
'bi bi-diagram-3-fill',
|
||||
'Soma das franquias (Geral)'
|
||||
);
|
||||
|
||||
const franquiaLineTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaLine)
|
||||
?? this.toNumberOrNull(this.resumo?.vivoLineTotals?.franquiaLine)
|
||||
?? (this.resumo?.vivoLineResumos ?? []).reduce(
|
||||
(acc, row) => acc + (this.toNumberOrNull(row?.franquiaLine) ?? 0),
|
||||
0
|
||||
);
|
||||
add(
|
||||
'franquia_line_total',
|
||||
'Total Franquia Line',
|
||||
this.formatDataAllowance(franquiaLineTotal),
|
||||
'bi bi-hdd-network-fill',
|
||||
'Soma da franquia line'
|
||||
);
|
||||
}
|
||||
add('vig_vencidos', 'Vigencia Vencida', this.formatInt(dashboard.vigenciaVencidos), 'bi bi-exclamation-triangle-fill', 'Prioridade alta');
|
||||
add('vig_30', 'Vence em 30 dias', this.formatInt(dashboard.vigenciaAVencer30), 'bi bi-calendar2-week-fill', 'Prioridade');
|
||||
add('mureg_30', 'MUREG 30 dias', this.formatInt(dashboard.muregsUltimos30Dias), 'bi bi-arrow-repeat', 'Movimentacao');
|
||||
|
|
@ -1395,8 +1005,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.chartResumoReservaDdd?.nativeElement,
|
||||
].filter(Boolean) as HTMLCanvasElement[];
|
||||
|
||||
if (!canvases.length) return;
|
||||
if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
|
||||
if (!canvases.length || canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
|
||||
this.scheduleResumoChartRetry();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1424,36 +1033,26 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
// 1. Status Pie
|
||||
if (this.chartStatusPie?.nativeElement) {
|
||||
const chartLabels = this.isCliente
|
||||
? ['Ativas']
|
||||
: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'];
|
||||
const chartData = this.isCliente
|
||||
? [this.statusResumo.ativos]
|
||||
: [
|
||||
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'],
|
||||
datasets: [{
|
||||
data: [
|
||||
this.statusResumo.ativos,
|
||||
this.statusResumo.perdaRoubo,
|
||||
this.statusResumo.bloq120,
|
||||
this.statusResumo.reservas,
|
||||
this.statusResumo.outras,
|
||||
];
|
||||
const chartColors = this.isCliente
|
||||
? [palette.status.ativos]
|
||||
: [
|
||||
this.statusResumo.outras
|
||||
],
|
||||
borderWidth: 0,
|
||||
backgroundColor: [
|
||||
palette.status.ativos,
|
||||
palette.status.blocked,
|
||||
palette.status.purple,
|
||||
palette.status.reserve,
|
||||
'#cbd5e1',
|
||||
];
|
||||
|
||||
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: [{
|
||||
data: chartData,
|
||||
borderWidth: 0,
|
||||
backgroundColor: chartColors,
|
||||
'#cbd5e1'
|
||||
],
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
|
|
@ -1559,7 +1158,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
labels: this.tipoChipLabels,
|
||||
datasets: [{
|
||||
data: this.tipoChipValues,
|
||||
backgroundColor: [palette.blue, palette.brand, '#94a3b8'],
|
||||
backgroundColor: [palette.blue, palette.brand],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
|
|
@ -1828,18 +1427,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||
return `${value} GB`;
|
||||
}
|
||||
|
||||
formatDataAllowance(v: any) {
|
||||
const n = this.toNumberOrNull(v);
|
||||
if (n === null) return '0 GB';
|
||||
if (n >= 1024) {
|
||||
const tb = n / 1024;
|
||||
const valueTb = tb.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
return `${valueTb} TB`;
|
||||
}
|
||||
const valueGb = n.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
return `${valueGb} GB`;
|
||||
}
|
||||
|
||||
private toNumberOrNull(v: any) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
|
|
|
|||
|
|
@ -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="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>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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 {
|
||||
|
|
@ -52,7 +51,6 @@ 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
|
||||
) {}
|
||||
|
|
@ -105,11 +103,9 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
deleteOpen = false;
|
||||
deleteTarget: BillingItem | null = null;
|
||||
|
||||
isSysAdmin = false;
|
||||
isAdmin = false;
|
||||
|
||||
private searchTimer: any = null;
|
||||
private searchResolvedClients: string[] = [];
|
||||
private searchResolveVersion = 0;
|
||||
|
||||
// cache do ALL
|
||||
private allCache: BillingItem[] = [];
|
||||
|
|
@ -164,7 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
this.initAnimations();
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
|
||||
setTimeout(() => {
|
||||
this.refreshData(true);
|
||||
|
|
@ -355,59 +351,22 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
onSearch() {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
|
||||
this.searchTimer = setTimeout(async () => {
|
||||
this.searchTimer = setTimeout(() => {
|
||||
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
|
||||
// --------------------------
|
||||
|
|
@ -554,12 +513,8 @@ 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) ||
|
||||
(resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente)))
|
||||
);
|
||||
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
|
||||
}
|
||||
|
||||
// KPIs
|
||||
|
|
@ -714,7 +669,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onEditar(r: BillingItem) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editOpen = true;
|
||||
|
|
@ -729,7 +684,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
onDelete(r: BillingItem) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
this.cdr.detectChanges();
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
import { buildBatchMassExampleText, buildBatchMassHeaderLine, buildBatchMassPreview, mergeMassRows } from './batch-mass-input.util';
|
||||
|
||||
describe('batch-mass-input.util', () => {
|
||||
it('parses rows separated by semicolon', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
'11999999999;8955000000000000001;Usuario 1;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01'
|
||||
);
|
||||
|
||||
expect(preview.separator).toBe('SEMICOLON');
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
expect(preview.rows[0].data['linha']).toBe('11999999999');
|
||||
expect(preview.rows[0].data['chip']).toBe('8955000000000000001');
|
||||
expect(preview.rows[0].data['planoContrato']).toBe('PLANO A');
|
||||
expect(preview.rows[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses rows separated by TAB', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
'11999999999\t8955000000000000001\tUsuario 1\teSIM\tPLANO A\tATIVO\tEMPRESA A\tCONTA A\t2026-01-01\t2027-01-01'
|
||||
);
|
||||
|
||||
expect(preview.separator).toBe('TAB');
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
expect(preview.rows[0].data['usuario']).toBe('Usuario 1');
|
||||
});
|
||||
|
||||
it('parses rows separated by pipe', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
'11999999999|8955000000000000001|Usuario 1|eSIM|PLANO A|ATIVO|EMPRESA A|CONTA A|2026-01-01|2027-01-01'
|
||||
);
|
||||
|
||||
expect(preview.separator).toBe('PIPE');
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
expect(preview.rows[0].data['tipoDeChip']).toBe('eSIM');
|
||||
});
|
||||
|
||||
it('ignores empty lines', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
'\n\n11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01\n\n'
|
||||
);
|
||||
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
});
|
||||
|
||||
it('detects and uses header row when present', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
[
|
||||
'Linha;ICCID;Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt Efetivacao Servico;Dt Termino Fidelizacao',
|
||||
'11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;01/02/2026;01/02/2027'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(preview.hasHeader).toBeTrue();
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
expect(preview.rows[0].data['dtEfetivacaoServico']).toBe('2026-02-01');
|
||||
expect(preview.rows[0].data['dtTerminoFidelizacao']).toBe('2027-02-01');
|
||||
});
|
||||
|
||||
it('maps official header labels with parentheses (Chip (ICCID), Empresa (Conta))', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
[
|
||||
'Linha;Chip (ICCID);Usuario;Tipo de Chip;Plano Contrato;Status;Empresa (Conta);Conta;Dt. Efetivação Serviço;Dt. Término Fidelização',
|
||||
'11999999999;8955000000000000001;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(preview.hasHeader).toBeTrue();
|
||||
expect(preview.recognizedRows).toBe(1);
|
||||
expect(preview.rows[0].data['chip']).toBe('8955000000000000001');
|
||||
expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA');
|
||||
expect(preview.rows[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('fills missing columns with defaults when available', () => {
|
||||
const preview = buildBatchMassPreview('11999999999;8955', {
|
||||
defaults: {
|
||||
planoContrato: 'PLANO PADRAO',
|
||||
status: 'ATIVO',
|
||||
contaEmpresa: 'EMPRESA A',
|
||||
conta: 'CONTA A',
|
||||
dtEfetivacaoServico: '2026-01-01',
|
||||
dtTerminoFidelizacao: '2027-01-01'
|
||||
}
|
||||
});
|
||||
|
||||
expect(preview.rows[0].data['planoContrato']).toBe('PLANO PADRAO');
|
||||
expect(preview.rows[0].data['status']).toBe('ATIVO');
|
||||
expect(preview.rows[0].errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses row value instead of default when both exist', () => {
|
||||
const preview = buildBatchMassPreview(
|
||||
'11999999999;8955;U1;eSIM;PLANO LINHA;SUSPENSO;EMPRESA LINHA;CONTA LINHA;2026-05-01;2027-05-01',
|
||||
{
|
||||
defaults: {
|
||||
planoContrato: 'PLANO PADRAO',
|
||||
status: 'ATIVO',
|
||||
contaEmpresa: 'EMPRESA PADRAO',
|
||||
conta: 'CONTA PADRAO',
|
||||
dtEfetivacaoServico: '2026-01-01',
|
||||
dtTerminoFidelizacao: '2027-01-01'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(preview.rows[0].data['planoContrato']).toBe('PLANO LINHA');
|
||||
expect(preview.rows[0].data['status']).toBe('SUSPENSO');
|
||||
expect(preview.rows[0].data['contaEmpresa']).toBe('EMPRESA LINHA');
|
||||
});
|
||||
|
||||
it('marks row invalid when required fields are missing', () => {
|
||||
const preview = buildBatchMassPreview('11999999999;8955');
|
||||
|
||||
expect(preview.invalidRows).toBe(1);
|
||||
expect(preview.rows[0].errors).toContain('Plano Contrato obrigatorio.');
|
||||
expect(preview.rows[0].errors).toContain('Status obrigatorio.');
|
||||
expect(preview.rows[0].errors).toContain('Empresa (Conta) obrigatoria.');
|
||||
expect(preview.rows[0].errors).toContain('Conta obrigatoria.');
|
||||
expect(preview.rows[0].errors).toContain('Dt. Efetivacao Servico obrigatoria.');
|
||||
expect(preview.rows[0].errors).toContain('Dt. Termino Fidelizacao obrigatoria.');
|
||||
});
|
||||
|
||||
it('detects duplicate line numbers inside the batch', () => {
|
||||
const text = [
|
||||
'11999999999;8955;U1;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01',
|
||||
'11 99999-9999;9999;U2;eSIM;PLANO;ATIVO;EMPRESA;CONTA;2026-01-01;2027-01-01'
|
||||
].join('\n');
|
||||
|
||||
const preview = buildBatchMassPreview(text);
|
||||
|
||||
expect(preview.duplicateRows).toBe(2);
|
||||
expect(preview.rows[0].errors).toContain('Linha duplicada no lote.');
|
||||
expect(preview.rows[1].errors).toContain('Linha duplicada no lote.');
|
||||
});
|
||||
|
||||
it('mergeMassRows keeps existing rows when mode is ADD', () => {
|
||||
const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'ADD');
|
||||
|
||||
expect(merged.map((x) => x.linha)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('mergeMassRows replaces existing rows when mode is REPLACE', () => {
|
||||
const merged = mergeMassRows([{ linha: '1' }], [{ linha: '2' }, { linha: '3' }], 'REPLACE');
|
||||
|
||||
expect(merged.map((x) => x.linha)).toEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('builds header and example using selected separator', () => {
|
||||
const header = buildBatchMassHeaderLine('TAB');
|
||||
const example = buildBatchMassExampleText('PIPE', true);
|
||||
|
||||
expect(header).toContain('\t');
|
||||
expect(example.split('\n')[0]).toContain('|');
|
||||
expect(example.split('\n').length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
export type BatchMassSeparatorMode = 'AUTO' | 'SEMICOLON' | 'TAB' | 'PIPE';
|
||||
export type BatchMassApplyMode = 'ADD' | 'REPLACE';
|
||||
|
||||
export interface BatchMassColumnGuideItem {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
canUseDefault: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BatchMassDefaults {
|
||||
usuario?: string;
|
||||
tipoDeChip?: string;
|
||||
planoContrato?: string;
|
||||
status?: string;
|
||||
contaEmpresa?: string;
|
||||
conta?: string;
|
||||
dtEfetivacaoServico?: string;
|
||||
dtTerminoFidelizacao?: string;
|
||||
}
|
||||
|
||||
export interface BatchMassPreviewRow {
|
||||
sourceLineNumber: number;
|
||||
rawLine: string;
|
||||
values: string[];
|
||||
data: Record<string, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BatchMassPreviewResult {
|
||||
separator: BatchMassSeparatorMode;
|
||||
recognizedRows: number;
|
||||
validRows: number;
|
||||
invalidRows: number;
|
||||
duplicateRows: number;
|
||||
hasHeader: boolean;
|
||||
rows: BatchMassPreviewRow[];
|
||||
parseErrors: string[];
|
||||
}
|
||||
|
||||
const SEQUENCE_KEYS = [
|
||||
'linha',
|
||||
'chip',
|
||||
'usuario',
|
||||
'tipoDeChip',
|
||||
'planoContrato',
|
||||
'status',
|
||||
'contaEmpresa',
|
||||
'conta',
|
||||
'dtEfetivacaoServico',
|
||||
'dtTerminoFidelizacao'
|
||||
] as const;
|
||||
|
||||
const SEQUENCE_LABELS: Record<(typeof SEQUENCE_KEYS)[number], string> = {
|
||||
linha: 'Linha',
|
||||
chip: 'Chip (ICCID)',
|
||||
usuario: 'Usuário',
|
||||
tipoDeChip: 'Tipo de Chip',
|
||||
planoContrato: 'Plano Contrato',
|
||||
status: 'Status',
|
||||
contaEmpresa: 'Empresa (Conta)',
|
||||
conta: 'Conta',
|
||||
dtEfetivacaoServico: 'Dt. Efetivação Serviço',
|
||||
dtTerminoFidelizacao: 'Dt. Término Fidelização'
|
||||
};
|
||||
|
||||
export const BATCH_MASS_COLUMN_GUIDE: BatchMassColumnGuideItem[] = [
|
||||
{ key: 'linha', label: 'Linha', required: true, canUseDefault: false, note: 'Número da linha (telefone).' },
|
||||
{ key: 'chip', label: 'Chip (ICCID)', required: true, canUseDefault: false, note: 'ICCID da linha.' },
|
||||
{ key: 'usuario', label: 'Usuário', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' },
|
||||
{ key: 'tipoDeChip', label: 'Tipo de Chip', required: false, canUseDefault: true, note: 'Pode vir por linha ou usar padrão.' },
|
||||
{ key: 'planoContrato', label: 'Plano Contrato', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' },
|
||||
{ key: 'status', label: 'Status', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' },
|
||||
{ key: 'contaEmpresa', label: 'Empresa (Conta)', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' },
|
||||
{ key: 'conta', label: 'Conta', required: true, canUseDefault: true, note: 'Obrigatório; pode variar por linha.' },
|
||||
{ key: 'dtEfetivacaoServico', label: 'Dt. Efetivação Serviço', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' },
|
||||
{ key: 'dtTerminoFidelizacao', label: 'Dt. Término Fidelização', required: true, canUseDefault: true, note: 'Aceita YYYY-MM-DD ou DD/MM/YYYY.' }
|
||||
];
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
'linha',
|
||||
'chip',
|
||||
'planoContrato',
|
||||
'status',
|
||||
'contaEmpresa',
|
||||
'conta',
|
||||
'dtEfetivacaoServico',
|
||||
'dtTerminoFidelizacao'
|
||||
] as const;
|
||||
|
||||
const HEADER_ALIAS_TO_KEY: Array<[string, (typeof SEQUENCE_KEYS)[number]]> = [
|
||||
['linha', 'linha'],
|
||||
['numero linha', 'linha'],
|
||||
['n linha', 'linha'],
|
||||
['telefone', 'linha'],
|
||||
['chip', 'chip'],
|
||||
['iccid', 'chip'],
|
||||
['chip iccid', 'chip'],
|
||||
['usuario', 'usuario'],
|
||||
['usuario da linha', 'usuario'],
|
||||
['tipo de chip', 'tipoDeChip'],
|
||||
['tipodechip', 'tipoDeChip'],
|
||||
['plano contrato', 'planoContrato'],
|
||||
['plano', 'planoContrato'],
|
||||
['status', 'status'],
|
||||
['empresa conta', 'contaEmpresa'],
|
||||
['empresa', 'contaEmpresa'],
|
||||
['conta empresa', 'contaEmpresa'],
|
||||
['conta', 'conta'],
|
||||
['dt efetivacao servico', 'dtEfetivacaoServico'],
|
||||
['data efetivacao servico', 'dtEfetivacaoServico'],
|
||||
['dt efetivacao', 'dtEfetivacaoServico'],
|
||||
['efetivacao', 'dtEfetivacaoServico'],
|
||||
['dt termino fidelizacao', 'dtTerminoFidelizacao'],
|
||||
['data termino fidelizacao', 'dtTerminoFidelizacao'],
|
||||
['termino fidelizacao', 'dtTerminoFidelizacao'],
|
||||
['fidelizacao', 'dtTerminoFidelizacao']
|
||||
];
|
||||
|
||||
function stripAccents(value: string): string {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
function normalizeHeaderCell(value: string): string {
|
||||
return stripAccents((value ?? '').toString())
|
||||
.toLowerCase()
|
||||
// Normalize punctuation like parentheses so headers such as
|
||||
// "Chip (ICCID)" and "Empresa (Conta)" match aliases.
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getSeparatorChar(mode: Exclude<BatchMassSeparatorMode, 'AUTO'>): string {
|
||||
if (mode === 'SEMICOLON') return ';';
|
||||
if (mode === 'TAB') return '\t';
|
||||
return '|';
|
||||
}
|
||||
|
||||
function getEffectiveSeparatorForTemplate(mode: BatchMassSeparatorMode): Exclude<BatchMassSeparatorMode, 'AUTO'> {
|
||||
return mode === 'AUTO' ? 'SEMICOLON' : mode;
|
||||
}
|
||||
|
||||
function detectSeparator(text: string): Exclude<BatchMassSeparatorMode, 'AUTO'> {
|
||||
const lines = text.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
||||
const first = lines[0] ?? '';
|
||||
const counts = {
|
||||
TAB: (first.match(/\t/g) ?? []).length,
|
||||
SEMICOLON: (first.match(/;/g) ?? []).length,
|
||||
PIPE: (first.match(/\|/g) ?? []).length
|
||||
} as const;
|
||||
|
||||
const entries = Object.entries(counts) as Array<[Exclude<BatchMassSeparatorMode, 'AUTO'>, number]>;
|
||||
entries.sort((a, b) => b[1] - a[1]);
|
||||
return entries[0]?.[1] ? entries[0][0] : 'SEMICOLON';
|
||||
}
|
||||
|
||||
function splitBySeparator(line: string, mode: BatchMassSeparatorMode): string[] {
|
||||
const effective: Exclude<BatchMassSeparatorMode, 'AUTO'> = mode === 'AUTO' ? detectSeparator(line) : mode;
|
||||
const sepChar = getSeparatorChar(effective);
|
||||
return line
|
||||
.split(sepChar)
|
||||
.map((x) => x.trim())
|
||||
.map((x) => (x === '""' ? '' : x));
|
||||
}
|
||||
|
||||
function resolveHeaderKey(cell: string): (typeof SEQUENCE_KEYS)[number] | null {
|
||||
const normalized = normalizeHeaderCell(cell);
|
||||
if (!normalized) return null;
|
||||
|
||||
const alias = HEADER_ALIAS_TO_KEY.find(([name]) => name === normalized);
|
||||
return alias?.[1] ?? null;
|
||||
}
|
||||
|
||||
function maybeNormalizeDate(value: string): string {
|
||||
const raw = (value ?? '').toString().trim();
|
||||
if (!raw) return '';
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||
if (/^\d{4}\/\d{2}\/\d{2}$/.test(raw)) return raw.replace(/\//g, '-');
|
||||
|
||||
const m = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/);
|
||||
if (m) {
|
||||
const day = m[1].padStart(2, '0');
|
||||
const month = m[2].padStart(2, '0');
|
||||
const year = m[3];
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeRowData(data: Record<string, string>): Record<string, string> {
|
||||
const next = { ...data };
|
||||
next['linha'] = (next['linha'] ?? '').toString().trim();
|
||||
next['chip'] = (next['chip'] ?? '').toString().trim();
|
||||
next['usuario'] = (next['usuario'] ?? '').toString().trim();
|
||||
next['tipoDeChip'] = (next['tipoDeChip'] ?? '').toString().trim();
|
||||
next['planoContrato'] = (next['planoContrato'] ?? '').toString().trim();
|
||||
next['status'] = (next['status'] ?? '').toString().trim();
|
||||
next['contaEmpresa'] = (next['contaEmpresa'] ?? '').toString().trim();
|
||||
next['conta'] = (next['conta'] ?? '').toString().trim();
|
||||
next['dtEfetivacaoServico'] = maybeNormalizeDate(next['dtEfetivacaoServico'] ?? '');
|
||||
next['dtTerminoFidelizacao'] = maybeNormalizeDate(next['dtTerminoFidelizacao'] ?? '');
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseHeaderMap(values: string[]): Map<number, (typeof SEQUENCE_KEYS)[number]> {
|
||||
const map = new Map<number, (typeof SEQUENCE_KEYS)[number]>();
|
||||
values.forEach((cell, idx) => {
|
||||
const key = resolveHeaderKey(cell);
|
||||
if (key) map.set(idx, key);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function looksLikeDataRow(values: string[]): boolean {
|
||||
const first = (values[0] ?? '').trim();
|
||||
const second = (values[1] ?? '').trim();
|
||||
const firstDigits = first.replace(/\D/g, '');
|
||||
const secondDigits = second.replace(/\D/g, '');
|
||||
|
||||
return firstDigits.length >= 8 || secondDigits.length >= 8;
|
||||
}
|
||||
|
||||
function buildDataFromValues(
|
||||
values: string[],
|
||||
defaults: BatchMassDefaults,
|
||||
headerMap?: Map<number, (typeof SEQUENCE_KEYS)[number]>
|
||||
): Record<string, string> {
|
||||
const base: Record<string, string> = {
|
||||
linha: '',
|
||||
chip: '',
|
||||
usuario: defaults.usuario?.toString().trim() ?? '',
|
||||
tipoDeChip: defaults.tipoDeChip?.toString().trim() ?? '',
|
||||
planoContrato: defaults.planoContrato?.toString().trim() ?? '',
|
||||
status: defaults.status?.toString().trim() ?? '',
|
||||
contaEmpresa: defaults.contaEmpresa?.toString().trim() ?? '',
|
||||
conta: defaults.conta?.toString().trim() ?? '',
|
||||
dtEfetivacaoServico: defaults.dtEfetivacaoServico?.toString().trim() ?? '',
|
||||
dtTerminoFidelizacao: defaults.dtTerminoFidelizacao?.toString().trim() ?? ''
|
||||
};
|
||||
|
||||
if (headerMap && headerMap.size > 0) {
|
||||
values.forEach((value, idx) => {
|
||||
const key = headerMap.get(idx);
|
||||
if (!key) return;
|
||||
base[key] = value;
|
||||
});
|
||||
return normalizeRowData(base);
|
||||
}
|
||||
|
||||
values.forEach((value, idx) => {
|
||||
const key = SEQUENCE_KEYS[idx];
|
||||
if (!key) return;
|
||||
base[key] = value;
|
||||
});
|
||||
|
||||
return normalizeRowData(base);
|
||||
}
|
||||
|
||||
function validatePreviewRows(rows: BatchMassPreviewRow[]): void {
|
||||
const linhaCounts = new Map<string, number>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const digits = (row.data['linha'] ?? '').replace(/\D/g, '');
|
||||
if (!digits) return;
|
||||
linhaCounts.set(digits, (linhaCounts.get(digits) ?? 0) + 1);
|
||||
});
|
||||
|
||||
rows.forEach((row) => {
|
||||
const errors: string[] = [];
|
||||
const linha = (row.data['linha'] ?? '').trim();
|
||||
const chip = (row.data['chip'] ?? '').trim();
|
||||
const linhaDigits = linha.replace(/\D/g, '');
|
||||
|
||||
if (!linha) errors.push('Linha obrigatoria.');
|
||||
else if (!linhaDigits) errors.push('Numero de linha invalido.');
|
||||
if (!chip) errors.push('Chip (ICCID) obrigatorio.');
|
||||
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
if (key === 'linha' || key === 'chip') return;
|
||||
if (!(row.data[key] ?? '').toString().trim()) {
|
||||
if (key === 'contaEmpresa') errors.push('Empresa (Conta) obrigatoria.');
|
||||
else if (key === 'planoContrato') errors.push('Plano Contrato obrigatorio.');
|
||||
else if (key === 'dtEfetivacaoServico') errors.push('Dt. Efetivacao Servico obrigatoria.');
|
||||
else if (key === 'dtTerminoFidelizacao') errors.push('Dt. Termino Fidelizacao obrigatoria.');
|
||||
else if (key === 'conta') errors.push('Conta obrigatoria.');
|
||||
else if (key === 'status') errors.push('Status obrigatorio.');
|
||||
}
|
||||
});
|
||||
|
||||
if (linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1) {
|
||||
errors.push('Linha duplicada no lote.');
|
||||
}
|
||||
|
||||
row.errors = errors;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBatchMassPreview(
|
||||
text: string,
|
||||
opts?: {
|
||||
separatorMode?: BatchMassSeparatorMode;
|
||||
defaults?: BatchMassDefaults;
|
||||
detectHeader?: boolean;
|
||||
}
|
||||
): BatchMassPreviewResult {
|
||||
const rawText = (text ?? '').toString();
|
||||
const separatorMode = opts?.separatorMode ?? 'AUTO';
|
||||
const defaults = opts?.defaults ?? {};
|
||||
const detectHeaderEnabled = opts?.detectHeader ?? true;
|
||||
const parseErrors: string[] = [];
|
||||
|
||||
const nonEmptyLines = rawText
|
||||
.split(/\r?\n/)
|
||||
.map((line, idx) => ({ line: line.trim(), sourceLineNumber: idx + 1 }))
|
||||
.filter((x) => x.line.length > 0);
|
||||
|
||||
if (nonEmptyLines.length === 0) {
|
||||
return {
|
||||
separator: separatorMode === 'AUTO' ? 'SEMICOLON' : separatorMode,
|
||||
recognizedRows: 0,
|
||||
validRows: 0,
|
||||
invalidRows: 0,
|
||||
duplicateRows: 0,
|
||||
hasHeader: false,
|
||||
rows: [],
|
||||
parseErrors: []
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveSeparator = separatorMode === 'AUTO' ? detectSeparator(nonEmptyLines[0].line) : separatorMode;
|
||||
const splitLines = nonEmptyLines.map((entry) => ({
|
||||
...entry,
|
||||
values: splitBySeparator(entry.line, effectiveSeparator)
|
||||
}));
|
||||
|
||||
let headerMap: Map<number, (typeof SEQUENCE_KEYS)[number]> | undefined;
|
||||
let hasHeader = false;
|
||||
if (detectHeaderEnabled && splitLines.length > 0) {
|
||||
const first = splitLines[0];
|
||||
const candidate = parseHeaderMap(first.values);
|
||||
const minAliases = looksLikeDataRow(first.values) ? 4 : 2;
|
||||
if (candidate.size >= minAliases) {
|
||||
headerMap = candidate;
|
||||
hasHeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
const rows: BatchMassPreviewRow[] = [];
|
||||
const startIndex = hasHeader ? 1 : 0;
|
||||
for (let i = startIndex; i < splitLines.length; i++) {
|
||||
const entry = splitLines[i];
|
||||
const allEmpty = entry.values.every((v) => !v.trim());
|
||||
if (allEmpty) continue;
|
||||
|
||||
const data = buildDataFromValues(entry.values, defaults, headerMap);
|
||||
rows.push({
|
||||
sourceLineNumber: entry.sourceLineNumber,
|
||||
rawLine: entry.line,
|
||||
values: entry.values,
|
||||
data,
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0 && hasHeader) {
|
||||
parseErrors.push('Nenhuma linha de dados encontrada abaixo do cabecalho.');
|
||||
}
|
||||
|
||||
validatePreviewRows(rows);
|
||||
|
||||
const duplicateRows = rows.filter((r) => r.errors.some((e) => e.includes('duplicada'))).length;
|
||||
const invalidRows = rows.filter((r) => r.errors.length > 0).length;
|
||||
|
||||
return {
|
||||
separator: effectiveSeparator,
|
||||
recognizedRows: rows.length,
|
||||
validRows: rows.length - invalidRows,
|
||||
invalidRows,
|
||||
duplicateRows,
|
||||
hasHeader,
|
||||
rows,
|
||||
parseErrors
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMassRows<T>(existing: T[], incoming: T[], mode: BatchMassApplyMode): T[] {
|
||||
return mode === 'REPLACE' ? [...incoming] : [...existing, ...incoming];
|
||||
}
|
||||
|
||||
export function buildBatchMassHeaderLine(mode: BatchMassSeparatorMode = 'SEMICOLON'): string {
|
||||
const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode));
|
||||
return SEQUENCE_KEYS.map((key) => SEQUENCE_LABELS[key]).join(sep);
|
||||
}
|
||||
|
||||
export function buildBatchMassExampleText(mode: BatchMassSeparatorMode = 'SEMICOLON', withHeader = true): string {
|
||||
const sep = getSeparatorChar(getEffectiveSeparatorForTemplate(mode));
|
||||
const lines: string[] = [];
|
||||
|
||||
if (withHeader) {
|
||||
lines.push(buildBatchMassHeaderLine(mode));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
[
|
||||
'11999999999',
|
||||
'8955000000000000001',
|
||||
'João',
|
||||
'eSIM',
|
||||
'SMART EMPRESAS 6GB',
|
||||
'ATIVO',
|
||||
'VIVO MACROPHONY',
|
||||
'0430237019',
|
||||
'2026-01-01',
|
||||
'2027-01-01'
|
||||
].join(sep)
|
||||
);
|
||||
|
||||
lines.push(
|
||||
[
|
||||
'11999999998',
|
||||
'8955000000000000002',
|
||||
'Maria',
|
||||
'Físico',
|
||||
'SMART EMPRESAS 10GB',
|
||||
'ATIVO',
|
||||
'VIVO MACROPHONY',
|
||||
'0430237019',
|
||||
'2026-01-02',
|
||||
'2027-01-02'
|
||||
].join(sep)
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -251,19 +251,6 @@
|
|||
|
||||
/* KPIs */
|
||||
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
|
||||
.geral-kpis.geral-kpis-client {
|
||||
grid-template-columns: repeat(3, minmax(180px, 240px));
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
grid-template-columns: repeat(2, minmax(170px, 1fr));
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(8px); transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.02); &:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1); background: #fff; border-color: var(--brand); } .lbl { font-size: 0.72rem; font-weight: 900; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); &.text-success { color: var(--success-text) !important; } &.text-danger { color: var(--danger-text) !important; } } .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } }
|
||||
.kpi .val-loading { font-size: 0.86rem; font-weight: 900; color: var(--muted); display: inline-flex; align-items: center; }
|
||||
|
||||
|
|
@ -349,118 +336,6 @@
|
|||
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 */
|
||||
|
|
@ -476,22 +351,6 @@
|
|||
.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); } }
|
||||
|
|
@ -517,64 +376,7 @@
|
|||
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-body .box-body { overflow: visible; }
|
||||
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
||||
.modal-card.modal-client-detail {
|
||||
width: min(560px, 95vw);
|
||||
}
|
||||
.modal-card.modal-client-detail .details-dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.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" */
|
||||
|
|
@ -627,81 +429,3 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
|
|||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } }
|
||||
.form-control, .form-select { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); background-color: #fff; font-size: 0.9rem; font-weight: 500; color: var(--text); transition: border-color 0.2s, box-shadow 0.2s; &:hover { border-color: rgba(17, 18, 20, 0.7); } &:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); outline: none; } &:disabled, &[readonly] { background-color: rgba(17, 18, 20, 0.04); border-color: rgba(17, 18, 20, 0.2); color: var(--muted); } }
|
||||
|
||||
/* === CREATE MODES / LOTE === */
|
||||
.create-entry-mode { display: grid; gap: 10px; }
|
||||
.mode-pill-group { display: inline-flex; flex-wrap: wrap; gap: 8px; background: rgba(255,255,255,0.75); padding: 6px; border-radius: 999px; border: 1px solid rgba(17,18,20,0.08); width: fit-content; max-width: 100%; }
|
||||
.mode-pill { border: 1px solid transparent; background: transparent; color: rgba(17,18,20,0.7); border-radius: 999px; padding: 8px 14px; font-size: 0.85rem; font-weight: 800; line-height: 1; transition: all 0.2s ease; white-space: nowrap; &:hover:not(:disabled) { background: rgba(227, 61, 207, 0.06); color: var(--brand); } &:disabled { opacity: 0.6; cursor: not-allowed; } &.active { background: linear-gradient(180deg, rgba(227,61,207,0.12), rgba(227,61,207,0.06)); color: var(--brand); border-color: rgba(227,61,207,0.18); box-shadow: 0 4px 12px rgba(227,61,207,0.08); } }
|
||||
.mode-helper { font-size: 0.83rem; color: rgba(17,18,20,0.65); background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; padding: 10px 12px; }
|
||||
|
||||
.batch-lines-panel { .detail-box { border-color: rgba(3,15,170,0.08); box-shadow: 0 10px 20px rgba(3,15,170,0.03); } }
|
||||
.batch-client-setup { .detail-box { border-color: rgba(17,18,20,0.08); } }
|
||||
.batch-count-badge { font-size: 0.72rem; font-weight: 900; color: var(--blue); background: rgba(3,15,170,0.08); border: 1px solid rgba(3,15,170,0.12); border-radius: 999px; padding: 3px 8px; }
|
||||
.batch-summary-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
||||
.summary-pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 5px 10px; font-size: 0.75rem; font-weight: 900; border: 1px solid rgba(17,18,20,0.08); background: #fff; color: rgba(17,18,20,0.72); &.total { color: var(--blue); background: rgba(3,15,170,0.04); border-color: rgba(3,15,170,0.12); } &.ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } &.warn { color: #b58105; background: rgba(255,193,7,0.14); border-color: rgba(255,193,7,0.18); } &.dup { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } }
|
||||
.batch-validation-banner { display: flex; align-items: center; gap: 8px; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px; font-size: 0.84rem; font-weight: 700; border: 1px solid rgba(17,18,20,0.08); background: rgba(255,255,255,0.7); color: rgba(17,18,20,0.72); i { font-size: 1rem; } &.is-danger { color: #842029; background: rgba(220,53,69,0.08); border-color: rgba(220,53,69,0.15); } &.is-ok { color: #157347; background: rgba(25,135,84,0.08); border-color: rgba(25,135,84,0.15); } }
|
||||
.batch-inheritance-note { border-radius: 12px; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.72); padding: 10px 12px; color: rgba(17,18,20,0.65); font-size: 0.82rem; line-height: 1.35; margin-bottom: 12px; }
|
||||
.batch-mass-input-box { border: 1px solid rgba(17,18,20,0.07); background: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0.72)); border-radius: 14px; padding: 12px; margin-bottom: 12px; display: grid; gap: 10px; }
|
||||
.batch-mass-input-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
||||
.batch-mass-title { font-size: 0.9rem; font-weight: 900; color: var(--text); }
|
||||
.batch-mass-sub { font-size: 0.76rem; color: rgba(17,18,20,0.62); line-height: 1.35; margin-top: 3px; code { font-size: 0.72rem; color: var(--blue); background: rgba(3,15,170,0.05); border: 1px solid rgba(3,15,170,0.08); border-radius: 6px; padding: 1px 4px; } }
|
||||
.batch-mass-controls { display: grid; gap: 4px; min-width: 150px; }
|
||||
.batch-mass-guide { border: 1px solid rgba(3,15,170,0.08); border-radius: 12px; background: rgba(3,15,170,0.02); overflow: hidden;
|
||||
summary { cursor: pointer; list-style: none; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: left; white-space: normal; } }
|
||||
}
|
||||
.batch-mass-guide-body { padding: 10px 12px 12px; border-top: 1px solid rgba(3,15,170,0.06); display: grid; gap: 10px; }
|
||||
.batch-mass-guide-list { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||
.batch-mass-guide-item { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid rgba(17,18,20,0.06); background: rgba(255,255,255,0.88); border-radius: 10px; padding: 8px; .pos { width: 28px; height: 28px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(3,15,170,0.08); color: var(--blue); font-weight: 900; font-size: 0.78rem; } .meta { min-width: 0; display: grid; gap: 2px; } .name { display: block; font-size: 0.79rem; font-weight: 800; color: var(--text); line-height: 1.25; } .hint { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.62); font-weight: 800; line-height: 1.2; } .note { display: block; font-size: 0.71rem; color: rgba(17,18,20,0.56); line-height: 1.25; } }
|
||||
.batch-mass-guide-note { border-radius: 8px; background: rgba(255,255,255,0.85); border: 1px solid rgba(17,18,20,0.05); padding: 8px 10px; font-size: 0.76rem; color: rgba(17,18,20,0.62); }
|
||||
.batch-mass-defaults { border: 1px solid rgba(17,18,20,0.06); border-radius: 12px; background: rgba(255,255,255,0.8); overflow: hidden;
|
||||
summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; font-weight: 800; color: rgba(17,18,20,0.78); background: rgba(3,15,170,0.02); small { font-size: 0.72rem; font-weight: 700; color: rgba(17,18,20,0.55); text-align: right; } }
|
||||
}
|
||||
.batch-mass-defaults-body { padding: 12px; border-top: 1px solid rgba(17,18,20,0.05); }
|
||||
.batch-mass-textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; line-height: 1.35; resize: vertical; min-height: 120px; }
|
||||
.batch-mass-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.batch-mass-preview { border-top: 1px solid rgba(17,18,20,0.06); padding-top: 10px; display: grid; gap: 8px; }
|
||||
.batch-mass-preview-pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.batch-mass-preview-errors { border-radius: 10px; border: 1px solid rgba(220,53,69,0.16); background: rgba(220,53,69,0.05); color: #842029; padding: 8px 10px; font-size: 0.78rem; ul { margin: 0; padding-left: 16px; } }
|
||||
.batch-mass-preview-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.06); border-radius: 10px; background: #fff; }
|
||||
.batch-mass-preview-table { width: 100%; min-width: 780px; border-collapse: separate; border-spacing: 0; th, td { padding: 8px 10px; border-bottom: 1px solid rgba(17,18,20,0.05); font-size: 0.76rem; vertical-align: top; } thead th { background: rgba(248,249,250,0.95); font-weight: 900; text-transform: uppercase; letter-spacing: 0.03em; color: rgba(17,18,20,0.62); white-space: nowrap; } tbody tr:last-child td { border-bottom: 0; } }
|
||||
.batch-mass-preview-foot { font-size: 0.74rem; color: rgba(17,18,20,0.58); padding: 8px 10px 0; }
|
||||
.batch-actions-row { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
|
||||
.batch-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 12px; align-items: start; }
|
||||
.batch-grid-pane { min-width: 0; display: grid; gap: 10px; }
|
||||
.batch-drawer-col { min-width: 0; }
|
||||
.batch-lines-empty { border: 1px dashed rgba(17,18,20,0.12); background: rgba(255,255,255,0.65); color: rgba(17,18,20,0.6); border-radius: 12px; padding: 14px; text-align: center; font-weight: 600; }
|
||||
.batch-lines-table-wrap { overflow: auto; border: 1px solid rgba(17,18,20,0.08); border-radius: 14px; background: #fff; }
|
||||
.batch-lines-table { width: 100%; min-width: 1010px; border-collapse: separate; border-spacing: 0; th, td { padding: 10px; border-bottom: 1px solid rgba(17,18,20,0.06); vertical-align: middle; } thead th { position: sticky; top: 0; z-index: 1; background: rgba(248, 249, 250, 0.96); font-size: 0.73rem; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(17,18,20,0.65); font-weight: 900; white-space: nowrap; } thead th:last-child { padding-left: 4px; padding-right: 4px; } tbody tr { transition: background-color 0.15s ease; &.is-selected { background: rgba(3,15,170,0.03); } &.is-invalid-row { background: rgba(220,53,69,0.025); } &:hover { background: rgba(227,61,207,0.03); } } tbody tr:last-child td { border-bottom: 0; } .index-cell { width: 64px; text-align: center; font-weight: 900; color: var(--blue); } .validation-cell { min-width: 160px; } .actions-cell { width: 76px; min-width: 76px; text-align: left; white-space: nowrap; padding-left: 0; padding-right: 2px; display: flex; align-items: center; justify-content: flex-start; gap: 4px; } .form-control { min-width: 140px; } }
|
||||
.batch-input-invalid { border-color: rgba(220,53,69,0.45) !important; background: rgba(220,53,69,0.03) !important; box-shadow: inset 0 0 0 1px rgba(220,53,69,0.08); }
|
||||
.batch-row-valid { display: inline-flex; align-items: center; gap: 6px; font-size: 0.76rem; font-weight: 900; color: #157347; background: rgba(25,135,84,0.07); border: 1px solid rgba(25,135,84,0.12); border-radius: 999px; padding: 5px 9px; }
|
||||
.batch-row-errors { margin: 0; padding-left: 14px; color: #842029; font-size: 0.74rem; line-height: 1.2; li + li { margin-top: 3px; } }
|
||||
.batch-row-errors-compact { display: grid; gap: 2px; color: #842029; }
|
||||
.batch-row-error-main { font-size: 0.76rem; font-weight: 800; line-height: 1.15; }
|
||||
.batch-row-more { font-size: 0.7rem; color: rgba(132,32,41,0.8); font-weight: 700; }
|
||||
.batch-detail-attention { border-color: rgba(220,53,69,0.25) !important; color: #842029 !important; background: rgba(220,53,69,0.06) !important; }
|
||||
.batch-selected-hint { margin-top: 10px; font-size: 0.8rem; color: rgba(17,18,20,0.6); display: flex; align-items: center; gap: 8px; i { color: var(--blue); } }
|
||||
.batch-detail-drawer { background: #fff; border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; box-shadow: 0 14px 28px rgba(17,18,20,0.08); overflow: hidden; display: flex; flex-direction: column; max-height: 72vh; position: sticky; top: 0; }
|
||||
.batch-detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(17,18,20,0.06); background: linear-gradient(180deg, rgba(3,15,170,0.03), rgba(255,255,255,0.9)); }
|
||||
.batch-detail-title { font-size: 0.95rem; font-weight: 900; color: var(--text); }
|
||||
.batch-detail-sub { margin-top: 2px; font-size: 0.76rem; color: rgba(17,18,20,0.6); }
|
||||
.batch-detail-body { padding: 12px; overflow: auto; }
|
||||
.batch-detail-body .detail-box { border-radius: 12px; }
|
||||
.batch-detail-placeholder { border: 1px dashed rgba(17,18,20,0.12); border-radius: 16px; background: rgba(255,255,255,0.72); padding: 18px; color: rgba(17,18,20,0.62); display: grid; gap: 8px; align-content: start; min-height: 180px; i { font-size: 1.4rem; color: var(--blue); } p { margin: 0; font-weight: 700; } small { color: rgba(17,18,20,0.58); } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mode-pill-group { width: 100%; border-radius: 14px; }
|
||||
.mode-pill { flex: 1 1 180px; justify-content: center; }
|
||||
.batch-actions-row .btn { flex: 1 1 200px; }
|
||||
.batch-mass-input-head { flex-direction: column; }
|
||||
.batch-mass-controls { min-width: 0; width: 100%; }
|
||||
.batch-mass-guide summary { flex-direction: column; align-items: flex-start; }
|
||||
.batch-mass-defaults summary { flex-direction: column; align-items: flex-start; }
|
||||
.batch-mass-actions .btn { flex: 1 1 180px; }
|
||||
.batch-editor-layout { grid-template-columns: 1fr; }
|
||||
.batch-detail-drawer { position: static; max-height: none; }
|
||||
.batch-detail-header { flex-direction: column; align-items: stretch; }
|
||||
.batch-detail-header > .d-flex { flex-wrap: wrap; }
|
||||
.batch-summary-strip { gap: 6px; }
|
||||
.summary-pill { font-size: 0.72rem; }
|
||||
.batch-validation-banner { align-items: flex-start; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,49 +28,4 @@ describe('Geral', () => {
|
|||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not create manual batch row automatically when switching to batch mode', () => {
|
||||
component.createBatchLines = [];
|
||||
component.setCreateEntryMode('BATCH');
|
||||
|
||||
expect(component.createEntryMode).toBe('BATCH');
|
||||
expect(component.createBatchLines.length).toBe(0);
|
||||
expect(component.batchDetailOpen).toBeFalse();
|
||||
});
|
||||
|
||||
it('should add parsed mass-input rows to existing batch and keep rows editable', async () => {
|
||||
spyOn<any>(component, 'showToast').and.resolveTo();
|
||||
component.createEntryMode = 'BATCH';
|
||||
component.batchMassInputText =
|
||||
'11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01';
|
||||
|
||||
await component.applyBatchMassInput('ADD');
|
||||
|
||||
expect(component.createBatchLines.length).toBe(1);
|
||||
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO A');
|
||||
|
||||
component.createBatchLines[0]['planoContrato'] = 'PLANO EDITADO';
|
||||
component.onBatchLineDetailsChange();
|
||||
|
||||
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO EDITADO');
|
||||
});
|
||||
|
||||
it('should replace current batch when applying mass input in replace mode', async () => {
|
||||
spyOn<any>(component, 'showToast').and.resolveTo();
|
||||
component.createEntryMode = 'BATCH';
|
||||
component.batchMassInputText =
|
||||
'11999999999;8955000000000000001;Joao;eSIM;PLANO A;ATIVO;EMPRESA A;CONTA A;2026-01-01;2027-01-01';
|
||||
|
||||
await component.applyBatchMassInput('ADD');
|
||||
expect(component.createBatchLines.length).toBe(1);
|
||||
|
||||
component.batchMassInputText =
|
||||
'11888888888;8955000000000000002;Maria;FISICO;PLANO B;ATIVO;EMPRESA B;CONTA B;2026-02-01;2027-02-01';
|
||||
|
||||
await component.applyBatchMassInput('REPLACE');
|
||||
|
||||
expect(component.createBatchLines.length).toBe(1);
|
||||
expect(component.createBatchLines[0].linha).toBe('11888888888');
|
||||
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -87,11 +87,11 @@
|
|||
[disabled]="loading">
|
||||
</app-select>
|
||||
</div>
|
||||
<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 class="filter-field">
|
||||
<label>Usuário (ID)</label>
|
||||
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" />
|
||||
</div>
|
||||
<div class="filter-field filter-search">
|
||||
<div class="filter-field">
|
||||
<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 class="entity-col">
|
||||
<td>
|
||||
<div class="entity-cell">
|
||||
<div class="entity-label td-clip" [title]="displayEntity(log)">
|
||||
{{ displayEntity(log) }}
|
||||
|
|
@ -164,6 +164,7 @@
|
|||
<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,7 +214,6 @@
|
|||
.filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
|
|
@ -225,9 +224,6 @@
|
|||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
|
|
@ -248,25 +244,11 @@
|
|||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-user {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-group {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
max-width: 270px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(17, 18, 20, 0.15);
|
||||
|
|
@ -280,7 +262,6 @@
|
|||
}
|
||||
|
||||
.input-group-text {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(17, 18, 20, 0.5);
|
||||
|
|
@ -291,13 +272,10 @@
|
|||
}
|
||||
|
||||
.form-control {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
height: auto;
|
||||
padding: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
|
|
@ -307,7 +285,6 @@
|
|||
}
|
||||
|
||||
.btn-clear {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(17, 18, 20, 0.45);
|
||||
|
|
@ -422,8 +399,7 @@
|
|||
|
||||
.table-modern th:nth-child(5),
|
||||
.table-modern td:nth-child(5) {
|
||||
text-align: center;
|
||||
min-width: 240px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-modern th:nth-child(2),
|
||||
|
|
@ -471,15 +447,13 @@
|
|||
.entity-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entity-label {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
|
|
@ -703,10 +677,10 @@
|
|||
.entity-cell {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.entity-label { flex: 1 1 auto; min-width: 0; text-align: center; }
|
||||
.entity-label { flex: 1 1 auto; min-width: 0; }
|
||||
.expand-btn { align-self: center; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
|
|
@ -896,14 +870,18 @@
|
|||
}
|
||||
|
||||
.entity-cell {
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.entity-label {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
margin-top: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.details-row td {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export class Historico implements OnInit {
|
|||
|
||||
filterPageName = '';
|
||||
filterAction = '';
|
||||
filterUser = '';
|
||||
filterUserId = '';
|
||||
filterSearch = '';
|
||||
dateFrom = '';
|
||||
dateTo = '';
|
||||
|
|
@ -84,7 +84,7 @@ export class Historico implements OnInit {
|
|||
clearFilters(): void {
|
||||
this.filterPageName = '';
|
||||
this.filterAction = '';
|
||||
this.filterUser = '';
|
||||
this.filterUserId = '';
|
||||
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,
|
||||
user: this.filterUser?.trim() || undefined,
|
||||
userId: this.filterUserId?.trim() || undefined,
|
||||
search: this.filterSearch?.trim() || undefined,
|
||||
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
|
||||
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@
|
|||
|
||||
<div class="left-content fade-in-up">
|
||||
<div class="brand-header mb-4">
|
||||
<div class="brand-logo" aria-label="Line Gestão">
|
||||
<img src="linegestao-logo.png" alt="Line Gestão" class="login-logo-symbol" />
|
||||
<div class="login-wordmark">
|
||||
<div class="login-wordmark__line">Line</div>
|
||||
<div class="login-wordmark__movel">Gestão</div>
|
||||
</div>
|
||||
<div class="brand-logo">
|
||||
<i class="bi bi-layers-fill"></i>
|
||||
<span>LineGestão</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -24,7 +21,7 @@
|
|||
type="email"
|
||||
id="email"
|
||||
formControlName="username"
|
||||
placeholder="usuario@empresa.com"
|
||||
placeholder="admin@empresa.com"
|
||||
[class.error]="hasError('username')"
|
||||
>
|
||||
<div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div>
|
||||
|
|
|
|||
|
|
@ -62,86 +62,14 @@
|
|||
margin-bottom: 32px;
|
||||
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-logo-symbol {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 8px 14px rgba(106, 13, 173, 0.2));
|
||||
}
|
||||
|
||||
.login-wordmark {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: 0.92;
|
||||
min-width: 0;
|
||||
--scale: 0.31;
|
||||
}
|
||||
|
||||
.login-wordmark__line {
|
||||
font-family: "Poppins", "Nunito", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: calc(96px * var(--scale));
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#c8c3ff 0%,
|
||||
#7a6cff 26%,
|
||||
#4b3fe6 52%,
|
||||
#2b21c8 74%,
|
||||
#120a78 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.login-wordmark__movel {
|
||||
font-family: "Poppins", "Nunito", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-size: calc(34px * var(--scale));
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
margin-left: calc(0.33em * var(--scale));
|
||||
margin-top: calc(-6px * var(--scale));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#aeb8c7 0%,
|
||||
#6b778d 50%,
|
||||
#3f4b60 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
color: var(--text-main);
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.login-logo-symbol {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.login-wordmark {
|
||||
--scale: 0.27;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.login-logo-symbol {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.login-wordmark {
|
||||
--scale: 0.24;
|
||||
}
|
||||
i { color: var(--brand-blue); font-size: 24px; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<i class="bi bi-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar..."
|
||||
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
|
||||
[(ngModel)]="search"
|
||||
(ngModelChange)="clearSelection()"
|
||||
/>
|
||||
|
|
@ -115,8 +115,8 @@
|
|||
class="list-item"
|
||||
*ngFor="let n of filteredNotifications"
|
||||
[class.is-read]="n.lida"
|
||||
[class.is-danger]="isVencido(n)"
|
||||
[class.is-warning]="isAVencer(n)"
|
||||
[class.is-danger]="getNotificationTipo(n) === 'Vencido'"
|
||||
[class.is-warning]="getNotificationTipo(n) === 'AVencer'"
|
||||
>
|
||||
<div class="status-strip"></div>
|
||||
|
||||
|
|
@ -126,12 +126,7 @@
|
|||
</label>
|
||||
|
||||
<div class="item-icon">
|
||||
<i
|
||||
class="bi"
|
||||
[class.bi-x-circle-fill]="isVencido(n)"
|
||||
[class.bi-clock-fill]="isAVencer(n)"
|
||||
[class.bi-check2-circle]="isAutoRenew(n)">
|
||||
</i>
|
||||
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i>
|
||||
</div>
|
||||
|
||||
<div class="item-content">
|
||||
|
|
@ -161,8 +156,8 @@
|
|||
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge-tag" [class.danger]="isVencido(n)" [class.warn]="isAVencer(n)" [class.info]="isAutoRenew(n)">
|
||||
{{ getStatusLabel(n) }}
|
||||
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
|
||||
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -178,27 +173,6 @@
|
|||
<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,7 +260,6 @@ $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; }
|
||||
|
|
@ -291,9 +290,9 @@ $border: #e5e7eb;
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
align-items: flex-end;
|
||||
min-width: 170px;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
|
|
@ -324,22 +323,17 @@ $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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-left: 12px; align-self: center;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: white; border: 1px solid $border;
|
||||
padding: 6px 10px; border-radius: 7px;
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: $text-main; font-size: 12px; font-weight: 600;
|
||||
color: $text-main; font-size: 13px; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
|
|
@ -349,406 +343,3 @@ $border: #e5e7eb;
|
|||
/* Mobile optimization: show button usually only on hover desktop, always mobile */
|
||||
@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)
|
||||
========================================================================== */
|
||||
@media (max-width: 768px) {
|
||||
.wrap {
|
||||
padding: calc(var(--app-header-offset, 72px) - 8px) 0 24px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
line-height: 1.12;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.35px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.pill {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
gap: 5px;
|
||||
|
||||
.count-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
min-height: 40px;
|
||||
|
||||
i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
font-size: 16px; /* evita zoom no iPhone */
|
||||
line-height: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba($text-secondary, 0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bulk-actions-bar {
|
||||
margin-top: 12px;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bulk-left {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.35px;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.state-container {
|
||||
padding: 24px 14px;
|
||||
}
|
||||
|
||||
.empty-state-large {
|
||||
padding: 32px 14px;
|
||||
|
||||
.illustration {
|
||||
font-size: 46px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 17px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border-radius: 14px;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.item-select {
|
||||
margin: 0;
|
||||
min-width: 20px;
|
||||
align-self: center;
|
||||
|
||||
input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-top {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-client {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.date-stack {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
font-size: 10px;
|
||||
padding: 4px 7px;
|
||||
letter-spacing: 0.25px;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.item-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.badge-tag {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.35px;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
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: 6px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-action .d-none.d-md-inline {
|
||||
display: inline !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.wrap {
|
||||
padding: calc(var(--app-header-offset, 72px) - 10px) 0 24px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.header-text {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 6px 9px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 8px 9px;
|
||||
gap: 7px;
|
||||
|
||||
input::placeholder {
|
||||
font-size: 11px;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
font-size: 10px;
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.bulk-left {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.25px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
gap: 8px;
|
||||
padding: 11px 10px 11px 12px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
font-size: 10px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
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',
|
||||
|
|
@ -24,14 +22,9 @@ 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,
|
||||
private router: Router,
|
||||
private vigenciaService: VigenciaService
|
||||
) {}
|
||||
constructor(private notificationsService: NotificationsService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadNotifications();
|
||||
|
|
@ -131,11 +124,7 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
return parsed.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): string {
|
||||
if (notification.tipo === 'RenovacaoAutomatica') {
|
||||
return 'RenovacaoAutomatica';
|
||||
}
|
||||
|
||||
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
|
||||
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||
const parsed = this.parseDateOnly(reference);
|
||||
if (!parsed) return notification.tipo;
|
||||
|
|
@ -144,94 +133,6 @@ 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;
|
||||
|
|
@ -378,8 +279,8 @@ export class Notificacoes implements OnInit, OnDestroy {
|
|||
|
||||
private shouldMarkRead(n: NotificationDto): boolean {
|
||||
if (this.filter === 'todas') return true;
|
||||
if (this.filter === 'aVencer') return this.isAVencer(n);
|
||||
if (this.filter === 'vencidas') return this.isVencido(n);
|
||||
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer';
|
||||
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido';
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -388,10 +289,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.isVencido(n));
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
|
||||
}
|
||||
if (this.filter === 'aVencer') {
|
||||
return this.notifications.filter(n => !n.lida && this.isAVencer(n));
|
||||
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
|
||||
}
|
||||
// "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="isSysAdmin"
|
||||
*ngIf="isAdmin"
|
||||
(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() isSysAdmin = false;
|
||||
@Input() isAdmin = false;
|
||||
|
||||
@Input() segment: ParcelamentoSegment = 'todos';
|
||||
@Input() segmentCounts: Record<ParcelamentoSegment, number> = {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
[total]="total"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[isSysAdmin]="isSysAdmin"
|
||||
[isAdmin]="isAdmin"
|
||||
(segmentChange)="setSegment($event)"
|
||||
(detail)="openDetails($event)"
|
||||
(edit)="openEdit($event)"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
kpiCards: ParcelamentoKpi[] = [];
|
||||
activeChips: FilterChip[] = [];
|
||||
|
||||
isSysAdmin = false;
|
||||
isAdmin = false;
|
||||
|
||||
detailOpen = false;
|
||||
detailLoading = false;
|
||||
|
|
@ -158,7 +158,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private syncPermissions(): void {
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
|
|
@ -440,7 +440,7 @@ export class Parcelamentos implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openDelete(item: ParcelamentoViewItem): void {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = item;
|
||||
this.deleteError = '';
|
||||
this.deleteOpen = true;
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
<section class="system-provision-page">
|
||||
<span class="page-blob blob-1" aria-hidden="true"></span>
|
||||
<span class="page-blob blob-2" aria-hidden="true"></span>
|
||||
<span class="page-blob blob-3" aria-hidden="true"></span>
|
||||
|
||||
<div class="container-shell">
|
||||
<div class="card-shell">
|
||||
<header class="card-header">
|
||||
<div class="title-badge">
|
||||
<i class="bi bi-shield-lock-fill"></i> SYSADMIN
|
||||
</div>
|
||||
<h1>Criar Credenciais do Cliente</h1>
|
||||
<p>Selecione o cliente e gere o acesso para acompanhamento das linhas no sistema.</p>
|
||||
</header>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="alert-box error" *ngIf="tenantsError">
|
||||
{{ tenantsError }}
|
||||
</div>
|
||||
|
||||
<div class="alert-box success" *ngIf="successMessage">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<div class="alert-box error" *ngIf="submitErrors.length">
|
||||
<strong>Falha ao criar credencial:</strong>
|
||||
<ul>
|
||||
<li *ngFor="let err of submitErrors">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="provisionForm" (ngSubmit)="onSubmit()" class="provision-form" novalidate>
|
||||
<div class="form-grid">
|
||||
<div class="form-field span-2">
|
||||
<label for="tenantId">Cliente</label>
|
||||
<div class="select-row">
|
||||
<app-select
|
||||
id="tenantId"
|
||||
class="form-control"
|
||||
[options]="tenantOptions"
|
||||
labelKey="label"
|
||||
valueKey="value"
|
||||
formControlName="tenantId"
|
||||
[disabled]="tenantsLoading || tenantOptions.length === 0"
|
||||
[searchable]="true"
|
||||
searchPlaceholder="Pesquisar cliente..."
|
||||
[placeholder]="tenantsLoading ? 'Carregando clientes...' : 'Selecione um cliente...'"
|
||||
></app-select>
|
||||
<button type="button" class="btn btn-ghost" (click)="loadTenants()" [disabled]="tenantsLoading">
|
||||
{{ tenantsLoading ? 'Atualizando...' : 'Atualizar lista' }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="field-error" *ngIf="hasFieldError('tenantId', 'required')">
|
||||
Selecione um cliente.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="name">Nome</label>
|
||||
<input id="name" type="text" class="form-control" formControlName="name" placeholder="Nome do responsável" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
class="form-control"
|
||||
formControlName="email"
|
||||
placeholder="usuario@cliente.com"
|
||||
/>
|
||||
<small class="field-error" *ngIf="hasFieldError('email', 'required')">Email é obrigatório.</small>
|
||||
<small class="field-error" *ngIf="hasFieldError('email', 'email')">Email inválido.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="password">Senha</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
class="form-control"
|
||||
formControlName="password"
|
||||
placeholder="Defina uma senha"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small class="field-error" *ngIf="hasFieldError('password', 'required')">Senha é obrigatória.</small>
|
||||
<small class="field-error" *ngIf="hasFieldError('password', 'minlength')">Mínimo de 6 caracteres.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="confirmPassword">Confirmar senha</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
formControlName="confirmPassword"
|
||||
placeholder="Repita a senha"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small class="field-error" *ngIf="hasFieldError('confirmPassword', 'required')">
|
||||
Confirmação é obrigatória.
|
||||
</small>
|
||||
<small class="field-error" *ngIf="passwordMismatch">As senhas não conferem.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" [disabled]="submitting || provisionForm.invalid">
|
||||
<span *ngIf="!submitting">Criar credencial de acesso</span>
|
||||
<span *ngIf="submitting">Criando...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
:host {
|
||||
--brand: #e33dcf;
|
||||
--brand-dark: #8b2d7f;
|
||||
--text: #111214;
|
||||
--muted: rgba(17, 18, 20, 0.66);
|
||||
--danger: #dc3545;
|
||||
--success: #198754;
|
||||
--border: rgba(17, 18, 20, 0.12);
|
||||
--card-bg: rgba(255, 255, 255, 0.84);
|
||||
--surface: #ffffff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.system-provision-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px 12px 110px;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(780px 360px at 10% 0%, rgba(227, 61, 207, 0.16), transparent 60%),
|
||||
radial-gradient(900px 380px at 90% 16%, rgba(3, 15, 170, 0.12), transparent 62%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%);
|
||||
}
|
||||
|
||||
.page-blob {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
filter: blur(36px);
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
background: radial-gradient(circle at 40% 40%, rgba(227, 61, 207, 0.4), rgba(227, 61, 207, 0.08));
|
||||
|
||||
&.blob-1 {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
top: -150px;
|
||||
left: -160px;
|
||||
}
|
||||
|
||||
&.blob-2 {
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
top: -230px;
|
||||
right: -260px;
|
||||
}
|
||||
|
||||
&.blob-3 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
bottom: -160px;
|
||||
left: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.container-shell {
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-shell {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(227, 61, 207, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 50px rgba(17, 18, 20, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 28px 18px;
|
||||
border-bottom: 1px solid rgba(17, 18, 20, 0.08);
|
||||
|
||||
h1 {
|
||||
margin: 10px 0 6px;
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(227, 61, 207, 0.24);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--brand-dark);
|
||||
|
||||
i {
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 28px 28px;
|
||||
}
|
||||
|
||||
.alert-box {
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0 18px;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--danger);
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid rgba(220, 53, 69, 0.22);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--success);
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
border: 1px solid rgba(25, 135, 84, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
.provision-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
&.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
.select-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0 12px;
|
||||
font-size: 0.92rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(17, 18, 20, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.role-content {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
height: 40px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(120deg, #e33dcf, #b131a0);
|
||||
color: #fff;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: rgba(17, 18, 20, 0.16);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.card-header,
|
||||
.card-body {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.system-provision-page {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-field.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.select-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||
|
||||
import {
|
||||
SysadminService,
|
||||
SystemTenantDto,
|
||||
CreateSystemTenantUserResponse,
|
||||
} from '../../services/sysadmin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-system-provision-user',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CustomSelectComponent],
|
||||
templateUrl: './system-provision-user.html',
|
||||
styleUrls: ['./system-provision-user.scss'],
|
||||
})
|
||||
export class SystemProvisionUserPage implements OnInit {
|
||||
readonly sourceType = 'MobileLines.Cliente';
|
||||
|
||||
provisionForm: FormGroup;
|
||||
tenants: SystemTenantDto[] = [];
|
||||
tenantOptions: Array<{ label: string; value: string }> = [];
|
||||
tenantsLoading = false;
|
||||
tenantsError = '';
|
||||
|
||||
submitting = false;
|
||||
submitErrors: string[] = [];
|
||||
successMessage = '';
|
||||
createdUser: CreateSystemTenantUserResponse | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private sysadminService: SysadminService
|
||||
) {
|
||||
this.provisionForm = this.fb.group(
|
||||
{
|
||||
tenantId: ['', [Validators.required]],
|
||||
name: [''],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||
confirmPassword: ['', [Validators.required, Validators.minLength(6)]],
|
||||
roles: this.fb.control<string[]>(['cliente'], [Validators.required]),
|
||||
},
|
||||
{ validators: this.passwordsMatchValidator }
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTenants();
|
||||
}
|
||||
|
||||
loadTenants(): void {
|
||||
if (this.tenantsLoading) return;
|
||||
|
||||
this.tenantsLoading = true;
|
||||
this.tenantsError = '';
|
||||
this.syncTenantControlAvailability();
|
||||
|
||||
this.sysadminService
|
||||
.listTenants({ source: this.sourceType, active: true })
|
||||
.subscribe({
|
||||
next: (tenants) => {
|
||||
this.tenants = (tenants || []).slice().sort((a, b) =>
|
||||
(a.nomeOficial || '').localeCompare(b.nomeOficial || '', 'pt-BR', { sensitivity: 'base' })
|
||||
);
|
||||
this.tenantOptions = this.tenants.map((tenant) => ({
|
||||
label: tenant.nomeOficial || tenant.tenantId,
|
||||
value: tenant.tenantId,
|
||||
}));
|
||||
this.tenantsLoading = false;
|
||||
this.syncTenantControlAvailability();
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.tenantsLoading = false;
|
||||
this.tenants = [];
|
||||
this.tenantOptions = [];
|
||||
this.tenantsError = this.extractErrorMessage(
|
||||
err,
|
||||
'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.'
|
||||
);
|
||||
this.syncTenantControlAvailability();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.submitting) return;
|
||||
|
||||
this.successMessage = '';
|
||||
this.submitErrors = [];
|
||||
this.createdUser = null;
|
||||
|
||||
if (this.provisionForm.invalid) {
|
||||
this.provisionForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = String(this.provisionForm.get('tenantId')?.value ?? '').trim();
|
||||
const email = String(this.provisionForm.get('email')?.value ?? '').trim().toLowerCase();
|
||||
const nameRaw = String(this.provisionForm.get('name')?.value ?? '').trim();
|
||||
const password = String(this.provisionForm.get('password')?.value ?? '');
|
||||
|
||||
this.submitting = true;
|
||||
this.setFormDisabled(true);
|
||||
|
||||
this.sysadminService
|
||||
.createTenantUser(tenantId, {
|
||||
name: nameRaw,
|
||||
email,
|
||||
password,
|
||||
roles: ['cliente'],
|
||||
clientCredentialsOnly: true,
|
||||
})
|
||||
.subscribe({
|
||||
next: (created) => {
|
||||
this.submitting = false;
|
||||
this.setFormDisabled(false);
|
||||
|
||||
this.createdUser = created;
|
||||
const tenant = this.findTenantById(created.tenantId) ?? this.findTenantById(tenantId);
|
||||
const tenantName = tenant?.nomeOficial || 'cliente selecionado';
|
||||
this.successMessage = `Credencial de acesso criada com sucesso para ${tenantName}.`;
|
||||
|
||||
this.provisionForm.patchValue({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
roles: ['cliente'],
|
||||
});
|
||||
this.provisionForm.markAsPristine();
|
||||
this.provisionForm.markAsUntouched();
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.submitting = false;
|
||||
this.setFormDisabled(false);
|
||||
this.submitErrors = this.extractErrors(err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
trackByTenantId(_: number, tenant: SystemTenantDto): string {
|
||||
return tenant.tenantId;
|
||||
}
|
||||
|
||||
hasFieldError(field: string, error?: string): boolean {
|
||||
const control = this.provisionForm.get(field);
|
||||
if (!control) return false;
|
||||
if (error) return !!(control.touched && control.hasError(error));
|
||||
return !!(control.touched && control.invalid);
|
||||
}
|
||||
|
||||
get passwordMismatch(): boolean {
|
||||
const confirmTouched = this.provisionForm.get('confirmPassword')?.touched;
|
||||
return !!(confirmTouched && this.provisionForm.errors?.['passwordMismatch']);
|
||||
}
|
||||
|
||||
private findTenantById(tenantId: string): SystemTenantDto | undefined {
|
||||
return this.tenants.find((tenant) => tenant.tenantId === tenantId);
|
||||
}
|
||||
|
||||
private setFormDisabled(disabled: boolean): void {
|
||||
if (disabled) {
|
||||
this.provisionForm.disable({ emitEvent: false });
|
||||
return;
|
||||
}
|
||||
|
||||
this.provisionForm.enable({ emitEvent: false });
|
||||
this.syncTenantControlAvailability();
|
||||
}
|
||||
|
||||
private syncTenantControlAvailability(): void {
|
||||
const tenantControl = this.provisionForm.get('tenantId');
|
||||
if (!tenantControl) return;
|
||||
if (this.submitting) return;
|
||||
|
||||
const shouldDisable = this.tenantsLoading || this.tenants.length === 0;
|
||||
if (shouldDisable) {
|
||||
tenantControl.disable({ emitEvent: false });
|
||||
return;
|
||||
}
|
||||
|
||||
tenantControl.enable({ emitEvent: false });
|
||||
}
|
||||
|
||||
private extractErrors(err: HttpErrorResponse): string[] {
|
||||
const apiError = err?.error;
|
||||
|
||||
if (Array.isArray(apiError)) {
|
||||
const list = apiError.map((entry) => String(entry ?? '').trim()).filter(Boolean);
|
||||
if (list.length) return list;
|
||||
}
|
||||
|
||||
if (Array.isArray(apiError?.errors)) {
|
||||
const list = apiError.errors
|
||||
.map((entry: unknown) => String((entry as { message?: string })?.message ?? entry ?? '').trim())
|
||||
.filter(Boolean);
|
||||
if (list.length) return list;
|
||||
}
|
||||
|
||||
if (typeof apiError?.message === 'string' && apiError.message.trim()) {
|
||||
return [apiError.message.trim()];
|
||||
}
|
||||
|
||||
if (typeof apiError === 'string' && apiError.trim()) {
|
||||
return [apiError.trim()];
|
||||
}
|
||||
|
||||
if (err.status === 403) {
|
||||
return ['Acesso negado. Este recurso é exclusivo para sysadmin.'];
|
||||
}
|
||||
|
||||
return ['Não foi possível criar a credencial para o cliente selecionado.'];
|
||||
}
|
||||
|
||||
private extractErrorMessage(err: HttpErrorResponse, fallback: string): string {
|
||||
const messages = this.extractErrors(err);
|
||||
if (messages.length) return messages[0];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
|
||||
const password = group.get('password')?.value;
|
||||
const confirm = group.get('confirmPassword')?.value;
|
||||
if (!password || !confirm) return null;
|
||||
return password === confirm ? null : { passwordMismatch: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -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="isSysAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
|
||||
<button *ngIf="isAdmin" 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 class="plano-col">PLANO</th>
|
||||
<th>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 plano-col" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
|
||||
<td class="text-muted small td-clip" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
|
||||
|
||||
<td class="text-muted small fw-bold">
|
||||
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
|
||||
|
|
@ -146,19 +146,11 @@
|
|||
{{ (row.total || 0) | currency:'BRL' }}
|
||||
</td>
|
||||
|
||||
<td class="actions-col">
|
||||
<td>
|
||||
<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="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>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -244,12 +236,6 @@
|
|||
{{ 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,12 +286,9 @@
|
|||
.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 */
|
||||
.table-wrap { overflow: auto; }
|
||||
.inner-table-wrap { max-height: 500px; overflow: auto; }
|
||||
.inner-table-wrap { max-height: 500px; overflow-y: 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;
|
||||
|
|
@ -304,26 +301,9 @@
|
|||
.fw-black { font-weight: 950; }
|
||||
.text-brand { color: var(--brand) !important; }
|
||||
.text-blue { color: var(--blue) !important; }
|
||||
.td-clip {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.plano-col {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 280px;
|
||||
width: 280px;
|
||||
text-align: center;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
.actions-col { min-width: 152px; }
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
|
|
@ -332,27 +312,6 @@
|
|||
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 {
|
||||
|
|
@ -1021,8 +980,7 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
|||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 210px;
|
||||
width: 210px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
|
|
@ -1153,8 +1111,7 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
|||
}
|
||||
|
||||
.actions-col {
|
||||
min-width: 190px;
|
||||
width: 190px;
|
||||
min-width: 106px;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ 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';
|
||||
|
|
@ -100,34 +98,30 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
clientsFromGeral: string[] = [];
|
||||
planOptions: string[] = [];
|
||||
|
||||
isSysAdmin = false;
|
||||
isAdmin = 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 route: ActivatedRoute
|
||||
private planAutoFill: PlanAutoFillService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isSysAdmin = this.authService.hasRole('sysadmin');
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
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 {
|
||||
|
|
@ -259,21 +253,6 @@ 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);
|
||||
|
|
@ -294,27 +273,11 @@ 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.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
|
||||
|
|
@ -365,7 +328,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.preloadGeralClients();
|
||||
|
|
@ -544,7 +507,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
openDelete(r: VigenciaRow) {
|
||||
if (!this.isSysAdmin) return;
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
|
@ -593,66 +556,6 @@ 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;
|
||||
user?: string;
|
||||
userId?: 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.user) httpParams = httpParams.set('user', params.user);
|
||||
if (params.userId) httpParams = httpParams.set('userId', params.userId);
|
||||
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' | 'RenovacaoAutomatica' | string;
|
||||
export type NotificationTipo = 'AVencer' | 'Vencido';
|
||||
|
||||
export type NotificationDto = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export type SystemTenantDto = {
|
||||
tenantId: string;
|
||||
nomeOficial: string;
|
||||
};
|
||||
|
||||
export type ListSystemTenantsParams = {
|
||||
source?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type CreateSystemTenantUserPayload = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roles: string[];
|
||||
clientCredentialsOnly?: boolean;
|
||||
};
|
||||
|
||||
export type CreateSystemTenantUserResponse = {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SysadminService {
|
||||
private readonly baseApi: string;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
}
|
||||
|
||||
listTenants(params?: ListSystemTenantsParams): Observable<SystemTenantDto[]> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params?.source) {
|
||||
httpParams = httpParams.set('source', params.source);
|
||||
}
|
||||
if (typeof params?.active === 'boolean') {
|
||||
httpParams = httpParams.set('active', String(params.active));
|
||||
}
|
||||
|
||||
return this.http.get<SystemTenantDto[]>(`${this.baseApi}/system/tenants`, {
|
||||
params: httpParams,
|
||||
});
|
||||
}
|
||||
|
||||
createTenantUser(
|
||||
tenantId: string,
|
||||
payload: CreateSystemTenantUserPayload
|
||||
): Observable<CreateSystemTenantUserResponse> {
|
||||
return this.http.post<CreateSystemTenantUserResponse>(
|
||||
`${this.baseApi}/system/tenants/${tenantId}/users`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
|
|||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
|
||||
export type UserPermission = 'admin' | 'gestor';
|
||||
|
||||
export type UserDto = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@ 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;
|
||||
|
|
@ -44,9 +40,6 @@ export interface UpdateVigenciaRequest {
|
|||
}
|
||||
|
||||
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
|
||||
export interface ConfigureVigenciaRenewalRequest {
|
||||
years: 2;
|
||||
}
|
||||
|
||||
export interface VigenciaClientGroup {
|
||||
cliente: string;
|
||||
|
|
@ -125,8 +118,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Loading…
Reference in New Issue