Compare commits

...

11 Commits

Author SHA1 Message Date
Eduardo 6a8f2d78bd build: ajustar budget de estilo para deploy 2026-03-02 17:39:52 -03:00
Eduardo 851f8d6fc2 Feat: Deploy de Implementações/Ajustes 2026-03-02 17:24:31 -03:00
Leon 59c2cb828e feat: Estilizando tela de cliente e imputs 2026-03-02 15:27:21 -03:00
Eduardo 3f5c55162e Feat: Aplicando Alterações/Ajustes 2026-03-02 13:26:48 -03:00
Leon Nascimento Moreira 875345ea89
Merge pull request #30 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
2026-02-27 16:35:39 -03:00
Eduardo 4dcbfadd2c Feat: Corrigindo merge 2026-02-27 16:34:54 -03:00
Eduardo 43efc1dc85 chore: merge dev 2026-02-27 14:50:18 -03:00
Eduardo 096306e852 Feat: Adição Lote de Linhas 2026-02-27 14:28:50 -03:00
Leon Nascimento Moreira f78f1c891e
Merge pull request #29 from eduardolopesx03/line-gestao-clientUsers
feat: tela e fluxo de criação de usuário do cliente
2026-02-26 17:20:59 -03:00
Leon e88762c6da feat: tela e fluxo de criação de usuário do cliente 2026-02-26 17:17:45 -03:00
Eduardo ec3abc056f Adição Lote de Linhas 2026-02-25 11:34:51 -03:00
55 changed files with 6915 additions and 679 deletions

View File

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

View File

@ -52,7 +52,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "20kB",
"maximumError": "40kB"
"maximumError": "45kB"
}
],
"outputHashing": "all"

12
package-lock.json generated
View File

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

View File

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

BIN
public/linegestao-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

@ -8,7 +8,8 @@ import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard';
import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard';
import { sysadminOnlyGuard } from './guards/sysadmin-only.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
@ -19,6 +20,7 @@ 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 },
@ -27,16 +29,22 @@ export const routes: Routes = [
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], title: 'Faturamento' },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Faturamento' },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, adminGuard], title: 'Chips Controle Recebidos' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' },
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, adminGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{
path: 'system/fornecer-usuario',
component: SystemProvisionUserPage,
canActivate: [authGuard, sysadminOnlyGuard],
title: 'Criar Credenciais do Cliente',
},
// ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },

View File

@ -41,6 +41,7 @@ export class AppComponent {
'/parcelamentos',
'/historico',
'/perfil',
'/system',
];
constructor(

View File

@ -11,10 +11,31 @@
</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 options; trackBy: trackByValue"
*ngFor="let opt of filteredOptions; trackBy: trackByValue"
[class.selected]="isSelected(opt)"
(click)="selectOption(opt)"
>
@ -22,7 +43,7 @@
<i class="bi bi-check2" *ngIf="isSelected(opt)"></i>
</button>
<div class="app-select-empty" *ngIf="!options || options.length === 0">
<div class="app-select-empty" *ngIf="!filteredOptions || filteredOptions.length === 0">
Nenhuma opção
</div>
</div>

View File

@ -111,6 +111,59 @@
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;

View File

@ -23,9 +23,12 @@ 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 = () => {};
@ -63,10 +66,12 @@ 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 {
@ -84,6 +89,26 @@ 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];
@ -103,6 +128,14 @@ 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;

View File

@ -9,16 +9,24 @@
</button>
<a routerLink="/dashboard" class="logo-area" (click)="closeMenu()">
<div class="logo-icon">
<i class="bi bi-layers-fill"></i>
</div>
<div class="logo-text">
Line<span class="highlight">Gestão</span>
<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>
</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"
@ -184,13 +192,19 @@
<button type="button" class="options-item" (click)="goToProfile()">
<i class="bi bi-person-circle"></i> Perfil
</button>
<div class="divider"></div>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openCreateUserModal()">
<div class="divider" *ngIf="isSysAdmin"></div>
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openCreateUserModal()">
<i class="bi bi-person-plus"></i> Criar novo usuário
</button>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
<button type="button" class="options-item" *ngIf="isSysAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário
</button>
<button type="button" class="options-item" *ngIf="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
@ -203,8 +217,11 @@
<ng-template #publicHeader>
<a routerLink="/" class="logo-area">
<div class="logo-icon"><i class="bi bi-layers-fill"></i></div>
<div class="logo-text">Line<span class="highlight">Gestão</span></div>
<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>
</a>
<nav class="nav-links">
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
@ -290,7 +307,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>Gestão de Usuários</h3>
<h3>{{ manageModalTitle }}</h3>
<button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
@ -300,7 +317,7 @@
<div class="manage-search">
<div class="search-input-wrapper">
<i class="bi bi-search"></i>
<input type="text" placeholder="Buscar por nome ou email..." [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
<input type="text" [placeholder]="manageSearchPlaceholder" [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
</div>
</div>
@ -313,7 +330,7 @@
<thead>
<tr>
<th style="width: 40%;">Usuário</th>
<th style="width: 25%;" class="text-center">Permissão</th>
<th style="width: 25%;" class="text-center">Perfil</th>
<th style="width: 15%;" class="text-center">Status</th>
<th style="width: 20%;" class="text-center">Ações</th>
</tr>
@ -388,7 +405,7 @@
<div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div>
<div class="info-text">
<h4>{{ target.nome }}</h4>
<span>Editando perfil</span>
<span>{{ isManageClientsMode ? 'Editando credencial do cliente' : 'Editando perfil' }}</span>
</div>
</div>
@ -400,13 +417,13 @@
<form class="user-form refined-form" id="editUserHeaderForm" [formGroup]="editUserForm" (ngSubmit)="submitEditUser()">
<div class="form-row">
<div class="form-field">
<label for="editHeaderNome">Nome Completo</label>
<label for="editHeaderNome">{{ isManageClientsMode ? 'Nome do responsável' : 'Nome Completo' }}</label>
<input id="editHeaderNome" type="text" formControlName="nome" />
</div>
</div>
<div class="form-field">
<label for="editHeaderEmail">Email Corporativo</label>
<label for="editHeaderEmail">{{ isManageClientsMode ? 'Email de acesso' : 'Email Corporativo' }}</label>
<input id="editHeaderEmail" type="email" formControlName="email" />
</div>
@ -424,7 +441,13 @@
<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]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
<app-select
id="editHeaderPermissao"
formControlName="permissao"
[options]="editPermissionOptions"
labelKey="label"
valueKey="value"
placeholder="Selecione o nivel"></app-select>
</div>
<div class="form-field">
@ -449,7 +472,7 @@
(click)="confirmPermanentDeleteUser(target)"
[disabled]="editUserSubmitting"
[title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'">
Excluir Permanentemente
{{ isManageClientsMode ? 'Excluir Credencial' : '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">
@ -464,8 +487,8 @@
<div class="placeholder-icon">
<i class="bi bi-person-gear"></i>
</div>
<h3>Editar Usuário</h3>
<p>Selecione um usuário na lista para visualizar e editar os detalhes.</p>
<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>
</div>
</div>
</div>
@ -495,8 +518,11 @@
<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()">
<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>
<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>
</a>
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
</div>
@ -504,34 +530,34 @@
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</a>
<a routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" 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 routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
</a>
<a *ngIf="isAdmin" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-receipt"></i> <span>Faturamento</span>
</a>
<a *ngIf="isAdmin" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
</a>
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-clock-history"></i> <span>Histórico</span>
</a>
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
</a>
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
</a>
<a *ngIf="isAdmin" routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" 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 routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<a *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a>
</div>

View File

@ -10,6 +10,9 @@ $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; }
@ -39,15 +42,68 @@ $border-color: #e5e7eb;
}
.logo-area {
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);
display: flex; align-items: center; gap: 14px; text-decoration: none; color: #111827; min-width: 0;
}
.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; }
.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);
}
.nav-links { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 1; }
@ -55,16 +111,69 @@ $border-color: #e5e7eb;
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; }
.header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; }
.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;
@ -636,7 +745,7 @@ $border-color: #e5e7eb;
/* 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: 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;
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;
&.open { transform: translateX(0); }
}
.side-menu-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid $border-color; }
@ -654,7 +763,74 @@ $border-color: #e5e7eb;
transition: color 0.2s ease;
&:hover { color: $primary; }
}
.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-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-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;
@ -685,17 +861,46 @@ $border-color: #e5e7eb;
.logo-area {
min-width: 0;
.logo-text {
font-size: 16px;
white-space: nowrap;
}
.lg-wordmark {
--scale: 0.29;
}
.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));
@ -739,6 +944,7 @@ $border-color: #e5e7eb;
.header-inner {
gap: 8px;
flex-wrap: nowrap;
}
.logged-header {
@ -767,17 +973,51 @@ $border-color: #e5e7eb;
.logo-area {
gap: 8px;
min-width: 0;
.logo-icon {
width: 32px;
height: 32px;
font-size: 16px;
}
.logo-text {
font-size: 14px;
line-height: 1;
.logo-symbol {
width: 36px;
height: 36px;
}
.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 {
@ -809,10 +1049,23 @@ $border-color: #e5e7eb;
position: fixed;
top: calc(var(--app-header-offset, 76px) + 8px);
right: 8px;
width: min(260px, calc(100vw - 16px));
width: min(228px, calc(100vw - 16px));
padding: 4px;
border-radius: 12px;
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;
@ -912,6 +1165,20 @@ $border-color: #e5e7eb;
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);
@ -928,6 +1195,20 @@ $border-color: #e5e7eb;
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;
@ -937,6 +1218,58 @@ $border-color: #e5e7eb;
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;
@ -983,8 +1316,31 @@ $border-color: #e5e7eb;
}
@media (max-width: 420px) {
.logo-area .logo-text {
display: none;
.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;
}
.logged-actions {
@ -1006,6 +1362,48 @@ $border-color: #e5e7eb;
.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 {

View File

@ -2,6 +2,7 @@ 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';
@ -10,6 +11,7 @@ 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({
@ -31,7 +33,11 @@ export class Header implements AfterViewInit, OnDestroy {
manageUsersOpen = false;
isLoggedHeader = false;
isHome = false;
isAdmin = false;
isSysAdmin = false;
canViewAll = false;
clientTenantDisplayName = '';
private clientTenantNameTenantId: string | null = null;
private readonly baseApi: string;
notifications: NotificationDto[] = [];
notificationsLoading = false;
notificationsError = false;
@ -52,14 +58,16 @@ export class Header implements AfterViewInit, OnDestroy {
createUserForbidden = false;
createUserSuccess = '';
readonly permissionOptions = [
{ value: 'admin', label: 'Administrador' },
{ value: 'sysadmin', label: 'SysAdmin' },
{ 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;
@ -86,6 +94,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/parcelamentos',
'/historico',
'/perfil',
'/system',
];
constructor(
@ -93,10 +102,14 @@ 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)]],
@ -199,10 +212,24 @@ export class Header implements AfterViewInit, OnDestroy {
private syncPermissions() {
if (!isPlatformBrowser(this.platformId)) {
this.isAdmin = false;
this.isSysAdmin = false;
this.canViewAll = false;
this.clientTenantDisplayName = '';
this.clientTenantNameTenantId = null;
return;
}
this.isAdmin = this.authService.hasRole('admin');
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();
}
toggleMenu() {
@ -227,8 +254,14 @@ 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.isAdmin) return;
if (!this.isSysAdmin) return;
this.createUserOpen = true;
this.closeOptions();
this.resetCreateUserState();
@ -240,7 +273,17 @@ export class Header implements AfterViewInit, OnDestroy {
}
openManageUsersModal() {
if (!this.isAdmin) return;
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';
this.manageUsersOpen = true;
this.closeOptions();
this.resetManageUsersState();
@ -250,6 +293,7 @@ export class Header implements AfterViewInit, OnDestroy {
closeManageUsersModal() {
this.manageUsersOpen = false;
this.resetManageUsersState();
this.manageMode = 'users';
}
toggleNotifications() {
@ -343,7 +387,10 @@ export class Header implements AfterViewInit, OnDestroy {
}
getVigenciaLabel(notification: NotificationDto): string {
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em';
const tipo = this.getNotificationTipo(notification);
if (tipo === 'Vencido') return 'Venceu em';
if (tipo === 'AVencer') return 'Vence em';
return 'Atualizado em';
}
getVigenciaDate(notification: NotificationDto): string {
@ -357,7 +404,11 @@ export class Header implements AfterViewInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR');
}
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
getNotificationTipo(notification: NotificationDto): string {
if (notification.tipo === 'RenovacaoAutomatica') {
return 'RenovacaoAutomatica';
}
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo;
@ -403,6 +454,22 @@ 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)
@ -429,7 +496,8 @@ export class Header implements AfterViewInit, OnDestroy {
this.authService.logout();
this.optionsOpen = false;
this.notificationsOpen = false;
this.isAdmin = false;
this.isSysAdmin = false;
this.canViewAll = false;
this.router.navigate(['/']);
}
@ -591,7 +659,7 @@ export class Header implements AfterViewInit, OnDestroy {
this.createUserForm.markAllAsTouched();
return;
}
if (!this.isAdmin) {
if (!this.isSysAdmin) {
this.createUserForbidden = true;
return;
}
@ -639,6 +707,7 @@ 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,
})
@ -699,17 +768,23 @@ 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: full.permissao ?? '',
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: 'Erro ao carregar usuario.' }];
this.editUserErrors = [{ message: this.isManageClientsMode ? 'Erro ao carregar credencial do cliente.' : 'Erro ao carregar usuário.' }];
},
});
}
@ -729,12 +804,21 @@ 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.editUserForm.get('permissao')?.value || '').toString().trim();
const permissao = this.isManageClientsMode
? 'cliente'
: (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 (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) payload.permissao = permissao;
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 ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo;
const senha = (this.editUserForm.get('senha')?.value || '').toString();
@ -773,18 +857,25 @@ export class Header implements AfterViewInit, OnDestroy {
const merged = this.mergeUserUpdate(currentTarget, payload);
this.editUserSubmitting = false;
this.setEditFormDisabled(false);
this.editUserSuccess = `Usuario ${merged.nome} atualizado com sucesso.`;
this.editUserSuccess = this.isManageClientsMode
? `Credencial de ${merged.nome} atualizada com sucesso.`
: `Usuario ${merged.nome} atualizado com sucesso.`;
this.editUserTarget = merged;
this.editUserForm.patchValue({
nome: merged.nome ?? '',
email: merged.email ?? '',
permissao: merged.permissao ?? '',
permissao: this.isManageClientsMode ? 'cliente' : (merged.permissao ?? ''),
ativo: merged.ativo ?? true,
senha: '',
confirmarSenha: '',
});
this.upsertManageUser(merged);
this.showManageUsersFeedback(`Usuario ${merged.nome} atualizado com sucesso.`, 'success');
this.showManageUsersFeedback(
this.isManageClientsMode
? `Credencial de ${merged.nome} atualizada com sucesso.`
: `Usuario ${merged.nome} atualizado com sucesso.`,
'success'
);
},
error: (err: HttpErrorResponse) => {
this.editUserSubmitting = false;
@ -793,12 +884,17 @@ export class Header implements AfterViewInit, OnDestroy {
if (Array.isArray(apiErrors)) {
this.editUserErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao atualizar usuario.',
message: e?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
}));
} else {
this.editUserErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }];
this.editUserErrors = [{
message: err?.error?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.')
}];
}
this.showManageUsersFeedback(this.editUserErrors[0]?.message || 'Erro ao atualizar usuario.', 'error');
this.showManageUsersFeedback(
this.editUserErrors[0]?.message || (this.isManageClientsMode ? 'Erro ao atualizar credencial do cliente.' : 'Erro ao atualizar usuario.'),
'error'
);
},
});
}
@ -806,11 +902,13 @@ 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 Usuário' : 'Inativar Usuário',
title: nextActive ? `Reativar ${entity}` : `Inativar ${entity}`,
message: nextActive
? `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.`,
? `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.`,
confirmLabel: nextActive ? 'Reativar' : 'Inativar',
tone: nextActive ? 'neutral' : 'warning',
});
@ -824,11 +922,13 @@ export class Header implements AfterViewInit, OnDestroy {
this.editUserTarget = { ...this.editUserTarget, ativo: nextActive };
this.editUserForm.patchValue({ ativo: nextActive, senha: '', confirmarSenha: '' });
this.editUserErrors = [];
this.editUserSuccess = `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`;
this.editUserSuccess = this.isManageClientsMode
? `Credencial de ${user.nome} ${nextActive ? 'reativada' : 'inativada'} com sucesso.`
: `Usuario ${user.nome} ${nextActive ? 'reativado' : 'inativado'} com sucesso.`;
}
},
error: (err: HttpErrorResponse) => {
const message = err?.error?.message || `Erro ao ${actionLabel} usuario.`;
const message = err?.error?.message || `Erro ao ${actionLabel} ${this.isManageClientsMode ? 'credencial do cliente' : 'usuario'}.`;
if (this.editUserTarget?.id === user.id) {
this.editUserSuccess = '';
this.editUserErrors = [{ message }];
@ -839,7 +939,9 @@ export class Header implements AfterViewInit, OnDestroy {
async confirmPermanentDeleteUser(user: any) {
if (user?.ativo !== false) {
const message = 'Inative a conta antes de excluir permanentemente.';
const message = this.isManageClientsMode
? 'Inative a credencial antes de excluir permanentemente.'
: 'Inative a conta antes de excluir permanentemente.';
if (this.editUserTarget?.id === user?.id) {
this.editUserSuccess = '';
this.editUserErrors = [{ message }];
@ -849,7 +951,9 @@ export class Header implements AfterViewInit, OnDestroy {
return;
}
const confirmed = await confirmDeletionWithTyping(`o usuário ${user.nome}`);
const confirmed = await confirmDeletionWithTyping(
this.isManageClientsMode ? `a credencial do cliente ${user.nome}` : `o usuário ${user.nome}`
);
if (!confirmed) return;
this.usersService.delete(user.id).subscribe({
@ -862,8 +966,8 @@ export class Header implements AfterViewInit, OnDestroy {
error: (err: HttpErrorResponse) => {
const apiErrors = err?.error?.errors;
const message = Array.isArray(apiErrors)
? (apiErrors[0]?.message || 'Erro ao excluir usuario.')
: (err?.error?.message || 'Erro ao excluir usuario.');
? (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.'));
if (this.editUserTarget?.id === user.id) {
this.editUserSuccess = '';
@ -915,6 +1019,30 @@ 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();
}
@ -926,7 +1054,12 @@ export class Header implements AfterViewInit, OnDestroy {
private setEditFormDisabled(disabled: boolean) {
if (disabled) this.editUserForm.disable({ emitEvent: false });
else this.editUserForm.enable({ emitEvent: false });
else {
this.editUserForm.enable({ emitEvent: false });
if (this.isManageClientsMode) {
this.editUserForm.get('permissao')?.disable({ emitEvent: false });
}
}
}
private upsertManageUser(user: any) {
@ -1032,4 +1165,66 @@ 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)}`;
}
}

View File

@ -0,0 +1,27 @@
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;
};

View File

@ -3,7 +3,7 @@ import { CanActivateFn, Router } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { AuthService } from '../services/auth.service';
export const adminGuard: CanActivateFn = () => {
export const sysadminOrGestorGuard: CanActivateFn = () => {
const router = inject(Router);
const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
@ -18,8 +18,8 @@ export const adminGuard: CanActivateFn = () => {
return router.parseUrl('/login');
}
const isAdmin = authService.hasRole('admin');
if (!isAdmin) {
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
if (!hasAccess) {
return router.parseUrl('/dashboard');
}

View File

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

View File

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

View File

@ -32,7 +32,7 @@
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
<button *ngIf="isAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<button *ngIf="isSysAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button>
</div>
@ -153,8 +153,8 @@
<td>
<div class="action-group justify-content-center">
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
@ -231,34 +231,31 @@
<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 GERAL</span>
<span><i class="bi bi-link-45deg me-2"></i> Vínculo com Reserva</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente (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>
<label>Linha (RESERVA)</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 || !createModel.selectedClient"
[disabled]="createLinesLoading"
placeholder="Selecione uma linha da Reserva..."
></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>
@ -291,7 +288,10 @@
<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" inputmode="numeric" [(ngModel)]="createModel.linha" /></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-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,7 +357,26 @@
<label>Razão Social</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.razaoSocial" />
</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-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-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" />

View File

@ -24,6 +24,7 @@ interface LineOptionDto {
item: number;
linha: string | null;
usuario: string | null;
franquiaLine?: number | null;
label?: string;
}
@ -94,16 +95,18 @@ export class DadosUsuarios implements OnInit {
createSaving = false;
createModel: any = null;
createDateNascimento = '';
clientsFromGeral: string[] = [];
createFranquiaLineTotal = 0;
editFranquiaLineTotal = 0;
editSelectedLineId = '';
editLineOptions: LineOptionDto[] = [];
lineOptionsCreate: LineOptionDto[] = [];
readonly tipoPessoaOptions: SimpleOption[] = [
{ label: 'Pessoa Física', value: 'PF' },
{ label: 'Pessoa Jurídica', value: 'PJ' },
];
createClientsLoading = false;
createLinesLoading = false;
isAdmin = false;
isSysAdmin = false;
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
@ -117,7 +120,7 @@ export class DadosUsuarios implements OnInit {
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.fetch(1);
}
@ -283,7 +286,7 @@ export class DadosUsuarios implements OnInit {
closeDetails() { this.detailsOpen = false; }
openEdit(row: UserDataRow) {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => {
this.editingId = fullData.id;
@ -295,7 +298,11 @@ 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')
});
@ -307,6 +314,9 @@ export class DadosUsuarios implements OnInit {
this.editModel = null;
this.editDateNascimento = '';
this.editingId = null;
this.editSelectedLineId = '';
this.editLineOptions = [];
this.editFranquiaLineTotal = 0;
}
onEditTipoChange() {
@ -366,16 +376,17 @@ export class DadosUsuarios implements OnInit {
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
this.loadReserveLinesForSelects();
}
closeCreate() {
this.createOpen = false;
this.createSaving = false;
this.createModel = null;
this.createFranquiaLineTotal = 0;
}
private resetCreateModel() {
@ -397,33 +408,9 @@ 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() {
@ -438,12 +425,9 @@ export class DadosUsuarios implements OnInit {
}
}
private loadLinesForClient(cliente: string) {
const c = (cliente ?? '').trim();
if (!c) return;
private loadReserveLinesForSelects(onDone?: () => void) {
this.createLinesLoading = true;
this.linesService.getLinesByClient(c).subscribe({
this.linesService.getLinesByClient('RESERVA').subscribe({
next: (items: any[]) => {
const mapped: LineOptionDto[] = (items ?? [])
.filter(x => !!String(x?.id ?? '').trim())
@ -457,12 +441,16 @@ 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 GERAL.', 'danger');
this.showToast('Erro ao carregar linhas da Reserva.', 'danger');
onDone?.();
}
});
}
@ -477,13 +465,56 @@ 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.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
this.createFranquiaLineTotal = this.toNullableNumber(d.franquiaLine) ?? 0;
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 {
@ -491,6 +522,15 @@ 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;
@ -532,7 +572,7 @@ export class DadosUsuarios implements OnInit {
}
openDelete(row: UserDataRow) {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.deleteTarget = row;
this.deleteOpen = true;
}
@ -584,6 +624,11 @@ 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';

View File

@ -7,10 +7,12 @@
<div class="page-head fade-in-up">
<div class="head-content">
<div class="badge-pill">
<i class="bi bi-grid-1x2-fill"></i> Visão Geral
<i class="bi bi-grid-1x2-fill"></i> {{ isCliente ? 'Visão Cliente' : 'Visão Geral' }}
</div>
<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>
<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>
</div>
<div class="head-actions">
@ -27,7 +29,7 @@
</div>
</div>
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'">
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'" *ngIf="!isCliente || clientOverview.hasData">
<div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey">
<div class="hero-icon">
<i [class]="k.icon"></i>
@ -40,6 +42,7 @@
</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>
@ -322,6 +325,114 @@
</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>

View File

@ -163,6 +163,10 @@
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 {
@ -202,11 +206,11 @@
}
.hero-label {
font-size: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.03em;
letter-spacing: 0.02em;
}
.hero-value {

View File

@ -20,6 +20,7 @@ import {
ResumoResponse,
LineTotal,
} from '../../services/resumo.service';
import { AuthService } from '../../services/auth.service';
// --- Interfaces (Mantidas intactas para não quebrar contrato) ---
type KpiCard = {
@ -110,6 +111,7 @@ type InsightsChartSeries = {
type InsightsKpisVivo = {
qtdLinhas?: number | null;
totalFranquiaGb?: number | null;
totalFranquiaLine?: number | null;
totalBaseMensal?: number | null;
totalAdicionaisMensal?: number | null;
totalGeralMensal?: number | null;
@ -154,6 +156,13 @@ 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;
@ -163,13 +172,6 @@ type DashboardLineListItemDto = {
tipoDeChip?: string | null;
};
type DashboardLinesPageDto = {
page: number;
pageSize: number;
total: number;
items: DashboardLineListItemDto[];
};
type ResumoTopCliente = {
cliente: string;
linhas: number;
@ -192,6 +194,18 @@ 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,
@ -218,6 +232,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
loading = true;
errorMsg: string | null = null;
isCliente = false;
kpis: KpiCard[] = [];
@ -240,6 +255,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
statusResumo = {
total: 0,
ativos: 0,
bloqueadas: 0,
perdaRoubo: 0,
bloq120: 0,
reservas: 0,
@ -283,6 +299,17 @@ 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[] = [];
@ -331,6 +358,7 @@ 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(/\/+$/, '');
@ -340,6 +368,15 @@ 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();
@ -379,6 +416,245 @@ 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;
@ -438,6 +714,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
}
onResumoTopNChange() {
if (this.isCliente) {
void this.loadClientDashboardData();
return;
}
this.buildResumoDerived();
this.tryBuildResumoCharts();
}
@ -466,6 +746,7 @@ 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,
@ -525,6 +806,7 @@ 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')),
@ -772,6 +1054,7 @@ 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(
@ -890,6 +1173,58 @@ 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 {}
@ -912,6 +1247,43 @@ 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) => {
@ -927,15 +1299,33 @@ 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');
if (insights) {
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
);
add(
'franquia_vivo_total',
'Total Franquia Vivo',
this.formatGb(this.toNumberOrNull(insights.vivo?.totalFranquiaGb) ?? 0),
this.formatDataAllowance(franquiaVivoTotal),
'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');
@ -1005,7 +1395,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.chartResumoReservaDdd?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[];
if (!canvases.length || canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
if (!canvases.length) return;
if (canvases.some((c) => c.clientWidth === 0 || c.clientHeight === 0)) {
this.scheduleResumoChartRetry();
return;
}
@ -1033,26 +1424,36 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
// 1. Status Pie
if (this.chartStatusPie?.nativeElement) {
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
type: 'doughnut',
data: {
labels: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'],
datasets: [{
data: [
const chartLabels = this.isCliente
? ['Ativas']
: ['Ativos', 'Perda/Roubo', 'Bloq 120d', 'Reservas', 'Outros'];
const chartData = this.isCliente
? [this.statusResumo.ativos]
: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras
],
borderWidth: 0,
backgroundColor: [
this.statusResumo.outras,
];
const chartColors = this.isCliente
? [palette.status.ativos]
: [
palette.status.ativos,
palette.status.blocked,
palette.status.purple,
palette.status.reserve,
'#cbd5e1'
],
'#cbd5e1',
];
this.chartPie = new Chart(this.chartStatusPie.nativeElement, {
type: 'doughnut',
data: {
labels: chartLabels,
datasets: [{
data: chartData,
borderWidth: 0,
backgroundColor: chartColors,
hoverOffset: 4
}]
},
@ -1158,7 +1559,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
labels: this.tipoChipLabels,
datasets: [{
data: this.tipoChipValues,
backgroundColor: [palette.blue, palette.brand],
backgroundColor: [palette.blue, palette.brand, '#94a3b8'],
borderWidth: 0,
hoverOffset: 4
}]
@ -1427,6 +1828,18 @@ 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;

View File

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

View File

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

View File

@ -0,0 +1,156 @@
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);
});
});

View File

@ -0,0 +1,438 @@
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

View File

@ -251,6 +251,19 @@
/* 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; }
@ -336,6 +349,118 @@
inset 0 1px 0 rgba(255,255,255,0.16);
}
}
.btn-send-reserva-group {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.42rem 0.85rem;
line-height: 1;
white-space: nowrap;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
background:
linear-gradient(135deg, rgba(3, 15, 170, 0.96), rgba(227, 61, 207, 0.9));
box-shadow:
0 10px 22px rgba(3, 15, 170, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease, opacity 0.18s ease;
i {
font-weight: 900;
}
&:hover:not(:disabled) {
color: #fff;
transform: translateY(-1px);
filter: saturate(1.05) brightness(1.02);
box-shadow:
0 14px 26px rgba(227, 61, 207, 0.16),
0 8px 18px rgba(3, 15, 170, 0.14);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24),
0 12px 24px rgba(227, 61, 207, 0.14);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow:
0 8px 16px rgba(3, 15, 170, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: 0 6px 14px rgba(17, 18, 20, 0.12);
}
}
.line-select-checkbox {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 6px;
border: 1.5px solid rgba(3, 15, 170, 0.35);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 246, 255, 0.95));
display: inline-grid;
place-content: center;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.12s ease;
position: relative;
vertical-align: middle;
&::after {
content: "";
width: 5px;
height: 9px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg) scale(0);
transform-origin: center;
transition: transform 0.16s ease;
margin-top: -1px;
}
&:hover {
border-color: rgba(227, 61, 207, 0.65);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
}
&:checked {
border-color: rgba(3, 15, 170, 0.95);
background: linear-gradient(135deg, rgba(3, 15, 170, 0.98), rgba(227, 61, 207, 0.95));
box-shadow:
0 0 0 3px rgba(3, 15, 170, 0.16),
0 4px 10px rgba(3, 15, 170, 0.24);
}
&:checked::after {
transform: rotate(45deg) scale(1);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24);
}
&:active {
transform: scale(0.96);
}
}
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
/* Inner Table Destravada */
@ -351,6 +476,22 @@
.action-group { display: flex; justify-content: center; gap: 8px; }
.btn-icon { width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer; &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); } &.danger:hover { color: var(--danger-text); background: var(--danger-bg); } }
/* Evita corte dos botões na última coluna de ações (grupos e tabela) */
.table-modern.table-modern-responsive th.actions-col-main,
.table-modern.table-modern-responsive td.actions-col-main {
min-width: 176px;
}
.table-modern.table-modern-responsive td.actions-col-main {
overflow: visible !important;
text-overflow: clip !important;
white-space: nowrap;
}
.table-modern.table-modern-responsive td.actions-col-main .action-group {
flex-wrap: nowrap;
}
/* Footer */
.geral-footer { padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; flex-shrink: 0; @media (max-width: 768px) { justify-content: center; text-align: center; } }
.pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: rgba(255,255,255,0.6); margin: 0 2px; &:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); } }
@ -376,7 +517,64 @@
.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" */
@ -429,3 +627,81 @@ 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; }
}

View File

@ -28,4 +28,49 @@ 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

View File

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

View File

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

View File

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

View File

@ -4,9 +4,12 @@
<div class="left-content fade-in-up">
<div class="brand-header mb-4">
<div class="brand-logo">
<i class="bi bi-layers-fill"></i>
<span>LineGestão</span>
<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>
</div>
@ -21,7 +24,7 @@
type="email"
id="email"
formControlName="username"
placeholder="admin@empresa.com"
placeholder="usuario@empresa.com"
[class.error]="hasError('username')"
>
<div class="error-msg" *ngIf="hasError('username')">E-mail obrigatório ou inválido.</div>

View File

@ -62,14 +62,86 @@
margin-bottom: 32px;
.brand-logo {
display: flex;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 700;
color: var(--text-main);
gap: 12px;
min-width: 0;
}
i { color: var(--brand-blue); font-size: 24px; }
.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;
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;
}
@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;
}
}
}

View File

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

View File

@ -260,6 +260,7 @@ $border: #e5e7eb;
.bi-x-circle-fill { color: $danger; }
.bi-clock-fill { color: $warning; }
.bi-check2-circle { color: $primary; }
}
.item-content { flex: 1; min-width: 0; }
@ -290,9 +291,9 @@ $border: #e5e7eb;
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
align-items: flex-start;
min-width: 170px;
text-align: right;
text-align: left;
}
.date-pill {
@ -323,17 +324,22 @@ $border: #e5e7eb;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
&.info { background: rgba($primary, 0.12); color: $primary; }
}
.item-actions {
margin-left: 12px; align-self: center;
margin-left: 12px;
align-self: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-action {
background: white; border: 1px solid $border;
padding: 8px 12px; border-radius: 8px;
padding: 6px 10px; border-radius: 7px;
cursor: pointer;
color: $text-main; font-size: 13px; font-weight: 600;
color: $text-main; font-size: 12px; font-weight: 600;
display: flex; align-items: center; gap: 6px;
transition: all 0.2s;
@ -343,3 +349,406 @@ $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;
}
}

View File

@ -1,9 +1,11 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
import { VigenciaService } from '../../services/vigencia.service';
@Component({
selector: 'app-notificacoes',
@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy {
bulkUnreadLoading = false;
exportLoading = false;
selectedIds = new Set<string>();
renewingKey: string | null = null;
private readonly subs = new Subscription();
constructor(private notificationsService: NotificationsService) {}
constructor(
private notificationsService: NotificationsService,
private router: Router,
private vigenciaService: VigenciaService
) {}
ngOnInit(): void {
this.loadNotifications();
@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR');
}
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
getNotificationTipo(notification: NotificationDto): string {
if (notification.tipo === 'RenovacaoAutomatica') {
return 'RenovacaoAutomatica';
}
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo;
@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed < today ? 'Vencido' : 'AVencer';
}
isVencido(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'Vencido';
}
isAVencer(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'AVencer';
}
isAutoRenew(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'RenovacaoAutomatica';
}
getStatusLabel(notification: NotificationDto): string {
if (this.isAutoRenew(notification)) return 'Renovação automática';
return this.isVencido(notification) ? 'Vencido' : 'A vencer';
}
goToVigencia(notification: NotificationDto): void {
const id = (notification.vigenciaLineId ?? '').trim();
const linha = (notification.linha ?? '').trim();
if (!id && !linha) return;
this.router.navigate(['/vigencia'], {
queryParams: { lineId: id || null, linha: linha || null, open: 'edit' }
});
}
renewFromNotification(notification: NotificationDto): void {
if (!this.isAVencer(notification)) return;
const years = 2;
const lockKey = notification.id;
if (this.renewingKey === lockKey) return;
this.renewingKey = lockKey;
const vigenciaLineId = (notification.vigenciaLineId ?? '').trim();
if (vigenciaLineId) {
this.scheduleByVigenciaId(vigenciaLineId);
return;
}
const linha = (notification.linha ?? '').trim();
if (!linha) {
this.renewingKey = null;
return;
}
const onlyDigits = linha.replace(/\D/g, '');
const lookup = onlyDigits || linha;
this.vigenciaService.getVigencia({
search: lookup,
page: 1,
pageSize: 20,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res) => {
const rows = res?.items ?? [];
const found = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0];
const id = (found?.id ?? '').trim();
if (!id) {
this.renewingKey = null;
return;
}
this.scheduleByVigenciaId(id);
},
error: () => {
this.renewingKey = null;
}
});
}
isRenewing(notification: NotificationDto): boolean {
return this.renewingKey === notification.id;
}
private scheduleByVigenciaId(id: string): void {
const years = 2;
this.vigenciaService.configureAutoRenew(id, { years }).subscribe({
next: () => {
this.renewingKey = null;
this.loadNotifications();
},
error: () => {
this.renewingKey = null;
}
});
}
private loadNotifications() {
this.loading = true;
this.error = false;
@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy {
private shouldMarkRead(n: NotificationDto): boolean {
if (this.filter === 'todas') return true;
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer';
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido';
if (this.filter === 'aVencer') return this.isAVencer(n);
if (this.filter === 'vencidas') return this.isVencido(n);
return false;
}
@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy {
return this.notifications.filter(n => n.lida);
}
if (this.filter === 'vencidas') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
return this.notifications.filter(n => !n.lida && this.isVencido(n));
}
if (this.filter === 'aVencer') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
return this.notifications.filter(n => !n.lida && this.isAVencer(n));
}
// "todas" aqui representa o inbox: pendentes (não lidas).
return this.notifications.filter(n => !n.lida);

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,117 @@
<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>

View File

@ -0,0 +1,298 @@
: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;
}
}

View File

@ -0,0 +1,238 @@
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 };
}
}

View File

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

View File

@ -286,9 +286,12 @@
.chip-muted { font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); }
/* TABELA MUREG STYLE */
.inner-table-wrap { max-height: 500px; overflow-y: auto; }
.table-wrap { overflow: auto; }
.inner-table-wrap { max-height: 500px; overflow: auto; }
.table-modern {
width: 100%; border-collapse: separate; border-spacing: 0;
width: 100%;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky; top: 0; z-index: 10; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px);
border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px;
@ -301,9 +304,26 @@
.fw-black { font-weight: 950; }
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.td-clip {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.actions-col { min-width: 152px; }
.plano-col {
max-width: 220px;
}
.actions-col {
min-width: 280px;
width: 280px;
text-align: center;
padding-left: 16px !important;
padding-right: 16px !important;
}
.action-group {
display: flex;
@ -312,6 +332,27 @@
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
width: 100%;
margin: 0 auto;
}
.renew-chip {
font-size: 0.66rem;
font-weight: 900;
color: #92400e;
background: rgba(245, 158, 11, 0.18);
border: 1px solid rgba(245, 158, 11, 0.38);
border-radius: 999px;
padding: 4px 8px;
}
.btn-xs {
min-height: 26px;
padding: 4px 7px;
font-size: 0.66rem;
font-weight: 800;
border-radius: 8px;
white-space: nowrap;
}
.btn-icon {
@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
}
.actions-col {
min-width: 120px;
min-width: 210px;
width: 210px;
}
.action-group {
@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
}
.actions-col {
min-width: 106px;
min-width: 190px;
width: 190px;
}
.action-group {

View File

@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
@ -98,30 +100,34 @@ export class VigenciaComponent implements OnInit, OnDestroy {
clientsFromGeral: string[] = [];
planOptions: string[] = [];
isAdmin = false;
isSysAdmin = false;
toastOpen = false;
toastMessage = '';
toastType: ToastType = 'success';
private toastTimer: any = null;
private searchTimer: any = null;
private readonly subs = new Subscription();
constructor(
private vigenciaService: VigenciaService,
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService
private planAutoFill: PlanAutoFillService,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('admin');
this.isSysAdmin = this.authService.hasRole('sysadmin');
this.loadClients();
this.loadPlanRules();
this.fetch(1);
this.bindOpenFromNotificationQuery();
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
this.subs.unsubscribe();
}
setView(mode: ViewMode): void {
@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return this.startOfDay(d) >= this.startOfDay(new Date());
}
public isAVencer(dateValue: any): boolean {
if (!dateValue) return false;
const d = this.parseAnyDate(dateValue);
if (!d) return false;
const today = this.startOfDay(new Date());
const end = this.startOfDay(d);
const days = Math.round((end.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
return days >= 0 && days <= 30;
}
getRenewalBadge(row: VigenciaRow): string {
if (!row.autoRenewYears) return '';
return `Auto +${row.autoRenewYears} ano(s)`;
}
public parseAnyDate(value: any): Date | null {
if (!value) return null;
const d = new Date(value);
@ -273,11 +294,27 @@ export class VigenciaComponent implements OnInit, OnDestroy {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.fetch(1);
}
scheduleAutoRenew(row: VigenciaRow): void {
if (!row?.id) return;
const years = 2;
this.vigenciaService.configureAutoRenew(row.id, { years }).subscribe({
next: () => {
row.autoRenewYears = years;
row.autoRenewReferenceEndDate = row.dtTerminoFidelizacao;
row.autoRenewConfiguredAt = new Date().toISOString();
this.showToast(`Renovação automática (+${years} ano${years > 1 ? 's' : ''}) programada.`, 'success');
},
error: () => this.showToast('Não foi possível programar a renovação automática.', 'danger')
});
}
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; }
openEdit(r: VigenciaRow) {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.editingId = r.id;
this.editModel = { ...r };
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
@ -328,7 +365,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
@ -507,7 +544,7 @@ export class VigenciaComponent implements OnInit, OnDestroy {
}
openDelete(r: VigenciaRow) {
if (!this.isAdmin) return;
if (!this.isSysAdmin) return;
this.deleteTarget = r;
this.deleteOpen = true;
}
@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
private bindOpenFromNotificationQuery(): void {
this.subs.add(
this.route.queryParamMap.subscribe((params) => {
const lineId = (params.get('lineId') ?? '').trim();
const linha = (params.get('linha') ?? '').trim();
if (!lineId && !linha) return;
const openMode = (params.get('open') ?? 'edit').trim().toLowerCase();
if (lineId) {
this.openVigenciaLineById(lineId, openMode);
} else if (linha) {
this.openVigenciaLineByNumber(linha, openMode);
}
})
);
}
private openVigenciaLineById(lineId: string, openMode: string): void {
this.vigenciaService.getById(lineId).subscribe({
next: (row) => {
if (this.isSysAdmin && openMode !== 'details') {
this.openEdit(row);
return;
}
this.openDetails(row);
},
error: () => this.showToast('Não foi possível abrir a linha da vigência pela notificação.', 'danger')
});
}
private openVigenciaLineByNumber(linha: string, openMode: string): void {
const onlyDigits = (linha || '').replace(/\D/g, '');
const lookup = onlyDigits || linha;
if (!lookup) return;
this.vigenciaService.getVigencia({
search: lookup,
page: 1,
pageSize: 20,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res) => {
const rows = res?.items ?? [];
const match = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0];
if (!match) {
this.showToast('Linha da notificação não encontrada na vigência.', 'danger');
return;
}
if (this.isSysAdmin && openMode !== 'details') {
this.openEdit(match);
return;
}
this.openDetails(match);
},
error: () => this.showToast('Não foi possível localizar a linha da notificação na vigência.', 'danger')
});
}
handleError(err: HttpErrorResponse, msg: string) {
this.loading = false;
this.expandedLoading = false;

View File

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

View File

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

View File

@ -0,0 +1,64 @@
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
);
}
}

View File

@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type UserPermission = 'admin' | 'gestor';
export type UserPermission = 'sysadmin' | 'gestor' | 'cliente';
export type UserDto = {
id: string;

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB