This commit is contained in:
Leon Nascimento Moreira 2026-02-26 17:18:57 -03:00 committed by GitHub
commit f85ef1a626
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 29720 additions and 3560 deletions

View File

@ -18,6 +18,7 @@
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"outputPath": "dist/line-gestao-frontend",
"polyfills": [
"zone.js"
],
@ -32,30 +33,37 @@
"styles": [
"src/styles.scss",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
}
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
],
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "2MB",
"maximumError": "3MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "40kB"
}
],
"outputHashing": "all"
},
"development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true

1998
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,7 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:line-gestao-frontend": "node dist/line-gestao-frontend/server/server.mjs"
"test": "ng test"
},
"prettier": {
"printWidth": 100,
@ -23,29 +22,25 @@
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/platform-server": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/ssr": "^20.3.10",
"@angular/common": "20.3.16",
"@angular/compiler": "20.3.16",
"@angular/core": "20.3.16",
"@angular/forms": "20.3.16",
"@angular/platform-browser": "20.3.16",
"@angular/router": "20.3.16",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"express": "^5.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.10",
"@angular/cli": "^20.3.10",
"@angular/compiler-cli": "^20.3.0",
"@angular/build": "20.3.16",
"@angular/cli": "20.3.16",
"@angular/compiler-cli": "20.3.16",
"@types/bootstrap": "^5.2.10",
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19",
"jasmine-core": "~5.9.0",
@ -55,5 +50,8 @@
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
},
"overrides": {
"qs": "^6.14.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
@Injectable()
export class AppTitleStrategy extends TitleStrategy {
private readonly appName = 'LineGestão';
constructor(private readonly titleService: Title) {
super();
}
override updateTitle(routerState: RouterStateSnapshot): void {
const pageTitle = this.buildTitle(routerState);
this.titleService.setTitle(
pageTitle ? `${pageTitle} - ${this.appName}` : this.appName
);
}
}

View File

@ -1,12 +0,0 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes))
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@ -1,26 +1,29 @@
import {
ApplicationConfig,
LOCALE_ID,
provideBrowserGlobalErrorListeners,
provideZoneChangeDetection
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideRouter, TitleStrategy } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
import { sessionInterceptor } from './interceptors/session.interceptor';
import { AppTitleStrategy } from './app-title.strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
{ provide: LOCALE_ID, useValue: 'pt-BR' },
provideRouter(routes),
provideClientHydration(withEventReplay()),
{ provide: TitleStrategy, useClass: AppTitleStrategy },
// ✅ HttpClient com fetch + interceptor
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor])
withInterceptors([authInterceptor, sessionInterceptor])
),
]
};

View File

@ -1,8 +0,0 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Prerender
}
];

View File

@ -8,28 +8,49 @@ import { Mureg } from './pages/mureg/mureg';
import { Faturamento } from './pages/faturamento/faturamento';
import { authGuard } from './guards/auth.guard';
import { adminGuard } from './guards/admin.guard';
import { systemAdminGuard } from './guards/system-admin.guard';
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
import { Relatorios } from './pages/relatorios/relatorios';
import { Dashboard } from './pages/dashboard/dashboard';
import { Notificacoes } from './pages/notificacoes/notificacoes';
import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos';
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 },
{ path: 'register', component: Register },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: Register, title: 'Cadastro' },
{ path: 'login', component: LoginComponent, title: 'Login' },
{ path: 'geral', component: Geral, canActivate: [authGuard] },
{ path: 'mureg', component: Mureg, canActivate: [authGuard] },
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
{ 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: '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: '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: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{
path: 'system/fornecer-usuario',
component: SystemProvisionUserPage,
canActivate: [authGuard, systemAdminGuard],
title: 'Fornecer Usuário',
},
// ✅ rota correta
{ path: 'relatorios', component: Relatorios, canActivate: [authGuard] },
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
// ✅ compatibilidade: se alguém acessar /portal/relatorios, manda pra /relatorios
{ path: 'portal/relatorios', redirectTo: 'relatorios', pathMatch: 'full' },
// ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard
{ path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: '**', redirectTo: '' },
];

View File

@ -1,10 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents();
});
@ -14,10 +22,10 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('should render title', () => {
it('should render app layout', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, line-gestao-frontend');
expect(compiled.querySelector('main.app-main')).toBeTruthy();
});
});

View File

@ -1,10 +1,11 @@
// src/app/app.ts
import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
import { CommonModule } from '@angular/common';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Header } from './components/header/header';
import { FooterComponent } from './components/footer/footer';
import { AuthService } from './services/auth.service';
@Component({
selector: 'app-root',
@ -33,11 +34,19 @@ export class AppComponent {
'/dadosusuarios',
'/vigencia',
'/trocanumero',
'/relatorios', // ✅ ADICIONADO: esconde footer na página de relatórios
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
'/notificacoes',
'/chips-controle-recebidos',
'/resumo',
'/parcelamentos',
'/historico',
'/perfil',
'/system',
];
constructor(
private router: Router,
private authService: AuthService,
@Inject(PLATFORM_ID) private platformId: object
) {
this.router.events.subscribe((event) => {
@ -56,10 +65,30 @@ export class AppComponent {
// ✅ footer some ao logar + também no login/register
this.hideFooter = isLoggedRoute || this.isFullScreenPage;
// Em SSR não existe storage do navegador.
if (!isPlatformBrowser(this.platformId)) return;
if (isLoggedRoute && !this.hasValidSession()) {
this.router.navigateByUrl('/login');
}
}
});
}
private hasValidSession(): boolean {
const token = this.authService.token;
if (!token) return false;
const payload = this.authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
this.authService.logout();
return false;
}
return true;
}
}
// ✅ SSR espera importar { App } de './app/app'
export { AppComponent as App };

View File

@ -2,11 +2,11 @@ import { Component, EventEmitter, Output, Input } from '@angular/core';
@Component({
selector: 'app-cta-button',
standalone: true,
templateUrl: './cta-button.html',
styleUrls: ['./cta-button.scss']
})
export class CtaButtonComponent {
@Input() label: string = 'COMEÇAR AGORA';
@Input() width: string = '250px';

View File

@ -0,0 +1,29 @@
<div class="app-select" [class.open]="isOpen" [class.disabled]="disabled" [class.sm]="size === 'sm'">
<button
type="button"
class="app-select-trigger"
(click)="toggle()"
[attr.aria-expanded]="isOpen"
[attr.aria-disabled]="disabled"
>
<span class="app-select-label">{{ displayLabel }}</span>
<i class="bi bi-chevron-down"></i>
</button>
<div class="app-select-panel" *ngIf="isOpen">
<button
type="button"
class="app-select-option"
*ngFor="let opt of options; trackBy: trackByValue"
[class.selected]="isSelected(opt)"
(click)="selectOption(opt)"
>
<span class="label">{{ getOptionLabel(opt) }}</span>
<i class="bi bi-check2" *ngIf="isSelected(opt)"></i>
</button>
<div class="app-select-empty" *ngIf="!options || options.length === 0">
Nenhuma opção
</div>
</div>
</div>

View File

@ -0,0 +1,145 @@
:host {
display: block;
width: 100%;
}
:host(.form-control),
:host(.form-select),
:host(.select-glass) {
/* Reset Bootstrap field skin on host to avoid duplicate "field behind" effect. */
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
background-image: none !important;
box-shadow: none !important;
height: auto !important;
min-height: 0 !important;
}
.app-select {
position: relative;
width: 100%;
}
.app-select-trigger {
position: relative;
width: 100%;
height: 42px;
border-radius: 10px;
border: 1.5px solid rgba(15, 23, 42, 0.12);
padding: 0 28px 0 12px;
background: #fff;
color: #0f172a;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
:host(.form-control) .app-select-trigger,
:host(.form-select) .app-select-trigger {
border-radius: 8px;
border: 1px solid rgba(17, 18, 20, 0.15);
font-size: 0.9rem;
}
:host(.select-glass) .app-select-trigger {
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(17, 18, 20, 0.15);
border-radius: 12px;
font-weight: 800;
}
:host(.select-glass) .app-select-trigger:hover {
background: #fff;
border-color: rgba(17, 18, 20, 0.7);
box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1);
}
.app-select.sm .app-select-trigger {
height: 36px;
font-size: 13px;
padding-right: 24px;
}
.app-select-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-select-trigger i {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 12px;
}
.app-select.open .app-select-trigger {
border-color: #e33dcf;
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
}
.app-select.disabled .app-select-trigger {
background-color: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
.app-select-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 260px;
overflow-y: auto;
background: #fff;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
z-index: 1200;
padding: 6px;
}
.app-select-option {
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
color: #0f172a;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.app-select-option:hover {
background: rgba(227, 61, 207, 0.08);
}
.app-select-option.selected {
background: rgba(227, 61, 207, 0.12);
color: #b71fb0;
font-weight: 600;
}
.app-select-empty {
padding: 12px 10px;
font-size: 12px;
color: #94a3b8;
}

View File

@ -0,0 +1,118 @@
import { Component, ElementRef, HostListener, Input, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-select',
standalone: true,
imports: [CommonModule],
templateUrl: './custom-select.html',
styleUrls: ['./custom-select.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomSelectComponent),
multi: true,
},
],
})
export class CustomSelectComponent implements ControlValueAccessor {
@Input() options: any[] = [];
@Input() placeholder = 'Selecione uma opção';
@Input() labelKey = 'label';
@Input() valueKey = 'value';
@Input() size: 'sm' | 'md' = 'md';
@Input() disabled = false;
isOpen = false;
value: any = null;
private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => {};
constructor(private host: ElementRef<HTMLElement>) {}
writeValue(value: any): void {
this.value = value;
}
registerOnChange(fn: (value: any) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) this.isOpen = false;
}
get displayLabel(): string {
const selected = this.findOption(this.value);
if (selected !== undefined) return this.getOptionLabel(selected);
if (this.value === null || this.value === undefined || this.value === '') return this.placeholder;
return String(this.value);
}
get hasValue(): boolean {
return !(this.value === null || this.value === undefined || this.value === '');
}
toggle(): void {
if (this.disabled) return;
this.isOpen = !this.isOpen;
}
close(): void {
this.isOpen = false;
}
selectOption(option: any): void {
if (this.disabled) return;
const value = this.getOptionValue(option);
this.value = value;
this.onChange(value);
this.onTouched();
this.close();
}
isSelected(option: any): boolean {
return this.getOptionValue(option) === this.value;
}
trackByValue = (_: number, option: any) => this.getOptionValue(option);
private getOptionValue(option: any): any {
if (option && typeof option === 'object') {
return option[this.valueKey];
}
return option;
}
getOptionLabel(option: any): string {
if (option && typeof option === 'object') {
const v = option[this.labelKey];
return v === undefined || v === null ? '' : String(v);
}
return option === undefined || option === null ? '' : String(option);
}
private findOption(value: any): any {
return (this.options || []).find((o) => this.getOptionValue(o) === value);
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.isOpen) return;
const target = event.target as Node | null;
if (target && this.host.nativeElement.contains(target)) return;
this.close();
}
@HostListener('document:keydown.escape')
onEsc(): void {
if (this.isOpen) this.close();
}
}

View File

@ -1,41 +1,219 @@
<header class="app-header" [class.scrolled]="isScrolled">
<div class="header-inner container">
<!-- ✅ LOGADO: hambúrguer + logo JUNTOS -->
<ng-container *ngIf="isLoggedHeader; else publicHeader">
<div class="left-logged">
<button class="btn-icon" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
<i class="bi bi-list"></i>
</button>
<div class="logged-header">
<div class="left-logged">
<button class="btn-icon hamburger" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
<i class="bi bi-list"></i>
</button>
<a routerLink="/geral" class="logo-area" (click)="closeMenu()">
<div class="logo-icon">
<i class="bi bi-layers-fill"></i>
<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>
</div>
</a>
</div>
<div class="logged-actions">
<div class="notifications-menu" [class.open]="notificationsOpen" (click)="$event.stopPropagation()">
<button
type="button"
class="btn-icon btn-bell"
aria-label="Notificações"
(click)="toggleNotifications()"
[attr.aria-expanded]="notificationsOpen"
[class.has-unread]="unreadCount > 0"
>
<i class="bi" [class.bi-bell-fill]="unreadCount > 0" [class.bi-bell]="unreadCount === 0"></i>
<span class="badge-pulse" *ngIf="unreadCount > 0"></span>
</button>
<div class="notifications-dropdown" *ngIf="notificationsOpen">
<div class="notifications-head">
<div class="head-title">
<span>Notificações</span>
<span class="badge-count" *ngIf="unreadCount > 0">{{ unreadCount }} nova(s)</span>
</div>
<div class="head-actions">
<button
type="button"
class="head-btn"
(click)="markAllNotificationsRead(); $event.stopPropagation()"
[disabled]="notificationsBulkReadLoading || unreadCount === 0"
*ngIf="notificationsView === 'pendentes'"
>
<span *ngIf="!notificationsBulkReadLoading"><i class="bi bi-check2-all"></i> Ler tudo</span>
<span *ngIf="notificationsBulkReadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Lendo...</span>
</button>
<button
type="button"
class="head-btn"
(click)="markAllNotificationsUnread(); $event.stopPropagation()"
[disabled]="notificationsBulkUnreadLoading || notificationsVisibleCount === 0"
*ngIf="notificationsView === 'lidas'"
>
<span *ngIf="!notificationsBulkUnreadLoading"><i class="bi bi-arrow-counterclockwise"></i> Restaurar tudo</span>
<span *ngIf="notificationsBulkUnreadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Restaurando...</span>
</button>
<a routerLink="/notificacoes" class="see-all" (click)="closeNotifications()">Ver tudo</a>
</div>
</div>
<div class="notifications-tabs">
<button
type="button"
class="notif-tab"
[class.active]="notificationsView === 'pendentes'"
(click)="setNotificationsView('pendentes'); $event.stopPropagation()"
>
Pendentes
</button>
<button
type="button"
class="notif-tab"
[class.active]="notificationsView === 'lidas'"
(click)="setNotificationsView('lidas'); $event.stopPropagation()"
>
Arquivadas / Lidas
</button>
</div>
<div class="notifications-body custom-scroll">
<div class="notifications-state loading" *ngIf="notificationsLoading">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span>Carregando...</span>
</div>
<div class="notifications-state warn" *ngIf="notificationsError">
<i class="bi bi-exclamation-triangle"></i>
<span>Falha ao carregar.</span>
</div>
<div class="notifications-empty" *ngIf="!notificationsLoading && !notificationsError && notificationsVisibleCount === 0">
<div class="empty-icon"><i class="bi bi-bell-slash"></i></div>
<p>Não há notificações no momento.</p>
</div>
<div class="notifications-state info" *ngIf="!notificationsLoading && !notificationsError && hasNotificationsTruncated">
<i class="bi bi-info-circle"></i>
<span class="notifications-truncate-copy">
Mostrando <strong>{{ notificationsPreviewLimit }}</strong> de <strong>{{ notificationsVisibleCount }}</strong> notificações
</span>
</div>
<div
class="notification-item"
*ngFor="let n of notificationsPreview; trackBy: trackByNotificationId"
[class.unread]="!n.lida"
(click)="onNotificationItemClick(n)"
>
<div class="notif-icon-area">
<div class="icon-circle"
[class.danger]="getNotificationTipo(n) === 'Vencido'"
[class.warn]="getNotificationTipo(n) === 'AVencer'">
<i class="bi"
[class.bi-x-lg]="getNotificationTipo(n) === 'Vencido'"
[class.bi-clock-history]="getNotificationTipo(n) === 'AVencer'"
[class.bi-info-circle]="getNotificationTipo(n) !== 'Vencido' && getNotificationTipo(n) !== 'AVencer'"></i>
</div>
</div>
<div class="notif-content">
<div class="notif-header">
<span class="notif-title-line">
<span class="notif-line">{{ n.linha || 'Sem Linha' }}</span>
<span class="notif-sep"></span>
<span class="notif-client" [title]="n.cliente || '-'">{{ abbreviateName(n.cliente) }}</span>
</span>
</div>
<p class="notif-desc">
<span class="notif-verb">{{ getVigenciaLabel(n) }}:</span>
<strong class="notif-date-strong" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
{{ getVigenciaDate(n) }}
</strong>
</p>
<div class="notif-meta-lines">
<div class="notif-meta-line">
<span class="meta-label">Usuário:</span>
<span class="meta-value">{{ abbreviateName(n.usuario) }}</span>
</div>
<div class="notif-meta-line">
<span class="meta-label">Conta:</span>
<span class="meta-value">{{ n.conta || '-' }}</span>
</div>
</div>
</div>
<div class="notif-status" *ngIf="!n.lida" title="Marcar como lida">
<span class="status-dot"></span>
</div>
<button
type="button"
class="notif-restore-btn"
*ngIf="n.lida"
(click)="markNotificationUnread(n); $event.stopPropagation()"
title="Marcar como não lida"
>
Restaurar
</button>
</div>
</div>
</div>
</div>
<div class="logo-text">
Line<span class="highlight">Gestão</span>
<div class="options-menu" [class.open]="optionsOpen" (click)="$event.stopPropagation()">
<button
type="button"
class="user-trigger"
(click)="toggleOptions()"
aria-haspopup="true"
[attr.aria-expanded]="optionsOpen"
>
<div class="user-avatar">
<i class="bi bi-person-fill"></i>
</div>
<i class="bi bi-chevron-down chevron"></i>
</button>
<div class="options-dropdown" *ngIf="optionsOpen">
<div class="dropdown-arrow"></div>
<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()">
<i class="bi bi-person-plus"></i> Criar novo usuário
</button>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário
</button>
<button type="button" class="options-item" *ngIf="isSystemAdmin" (click)="goToSystemProvisionUser()">
<i class="bi bi-shield-lock"></i> Fornecer usuário (cliente)
</button>
<div class="divider"></div>
<button type="button" class="options-item danger" (click)="logout()">
<i class="bi bi-box-arrow-right"></i> Sair
</button>
</div>
</div>
</a>
</div>
</div>
</ng-container>
<!-- ✅ PÚBLICO (HOME): menu + botão -->
<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>
<div class="logo-icon"><i class="bi bi-layers-fill"></i></div>
<div class="logo-text">Line<span class="highlight">Gestão</span></div>
</a>
<nav class="nav-links">
<a href="https://www.linemovel.com.br/empresas" target="_blank" class="nav-link">Para Empresas</a>
<a href="https://www.linemovel.com.br/proposta" target="_blank" class="nav-link">Proposta</a>
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
</nav>
<div class="header-actions">
<a routerLink="/login" class="btn-login-header">
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
@ -44,62 +222,323 @@
</ng-template>
</div>
<!-- ✅ faixa (só na home, opcional) -->
<div class="header-bar" *ngIf="!isLoggedHeader && isHome">
<span class="header-bar-text">Somos a escolha certa para estar sempre conectado!</span>
</div>
</header>
<!-- ✅ OVERLAY (logado) -->
<div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div>
<!-- ✅ MENU LATERAL (logado) -->
<aside
*ngIf="isLoggedHeader"
class="side-menu"
[class.open]="menuOpen"
(click)="$event.stopPropagation()"
>
<div class="side-menu-header">
<a class="side-logo" routerLink="/geral" (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>
</a>
<button type="button" class="close-btn" aria-label="Fechar menu" (click)="closeMenu()">
<div class="modal-overlay" *ngIf="createUserOpen" (click)="closeCreateUserModal()"></div>
<div class="modal-card" *ngIf="createUserOpen" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Novo Usuário LineGestão</h3>
<button type="button" class="btn-icon close-x" (click)="closeCreateUserModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-alert error" *ngIf="createUserForbidden">
Você não tem permissão para criar usuários.
</div>
<div class="form-alert error" *ngIf="!createUserForbidden && createUserErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of createUserErrors">
{{ err.message }}
</li>
</ul>
</div>
<div class="form-alert success" *ngIf="createUserSuccess">
{{ createUserSuccess }}
</div>
<form class="user-form" id="createUserForm" [formGroup]="createUserForm" (ngSubmit)="submitCreateUser()">
<div class="form-field" [class.has-error]="hasFieldError('nome') || (createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid)">
<label for="modalNome">Nome</label>
<input id="modalNome" type="text" placeholder="Nome completo" formControlName="nome" />
<small class="field-error" *ngIf="createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid">Nome obrigatório.</small>
<small class="field-error" *ngIf="getFieldErrors('nome').length">{{ getFieldErrors('nome')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('email') || (createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid)">
<label for="modalEmail">Email</label>
<input id="modalEmail" type="email" placeholder="nome@empresa.com" formControlName="email" />
<small class="field-error" *ngIf="createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid">Email inválido.</small>
<small class="field-error" *ngIf="getFieldErrors('email').length">{{ getFieldErrors('email')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('senha') || (createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid)">
<label for="modalSenha">Senha</label>
<input id="modalSenha" type="password" placeholder="Defina uma senha segura" formControlName="senha" />
<small class="field-error" *ngIf="createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid">Senha inválida.</small>
<small class="field-error" *ngIf="getFieldErrors('senha').length">{{ getFieldErrors('senha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('confirmarSenha') || (createUserForm.get('confirmarSenha')?.touched && createUserForm.get('confirmarSenha')?.invalid) || (passwordMismatch && createUserForm.get('confirmarSenha')?.touched)">
<label for="modalConfirmar">Confirmar Senha</label>
<input id="modalConfirmar" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" />
<small class="field-error" *ngIf="passwordMismatch && createUserForm.get('confirmarSenha')?.touched">As senhas não conferem.</small>
<small class="field-error" *ngIf="getFieldErrors('confirmarSenha').length">{{ getFieldErrors('confirmarSenha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('permissao') || (createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid)">
<label for="modalPermissoes">Permissões</label>
<app-select id="modalPermissoes" formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
<small class="field-error" *ngIf="createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid">Selecione uma permissão.</small>
<small class="field-error" *ngIf="getFieldErrors('permissao').length">{{ getFieldErrors('permissao')[0] }}</small>
</div>
</form>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" (click)="closeCreateUserModal()">Cancelar</button>
<button type="submit" form="createUserForm" class="btn-primary" [disabled]="createUserSubmitting || createUserForm.invalid">
<span *ngIf="!createUserSubmitting">Salvar</span>
<span *ngIf="createUserSubmitting">Salvando...</span>
</button>
</div>
</div>
<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>
<button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body manage-body">
<div class="manage-left">
<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()" />
</div>
</div>
<div class="manage-table-wrap custom-scroll">
<div class="loading-state" *ngIf="manageUsersLoading">
<div class="spinner-border text-primary" role="status"></div>
</div>
<table class="manage-table" *ngIf="!manageUsersLoading && manageUsers.length">
<thead>
<tr>
<th style="width: 40%;">Usuário</th>
<th style="width: 25%;" class="text-center">Permissão</th>
<th style="width: 15%;" class="text-center">Status</th>
<th style="width: 20%;" class="text-center">Ações</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let u of manageUsers" [class.selected]="editUserTarget?.id === u.id" (click)="openEditUser(u)">
<td>
<div class="user-cell">
<div class="avatar-mini">{{ u.nome.charAt(0).toUpperCase() }}</div>
<div class="user-info">
<span class="u-name" title="{{ u.nome }}">{{ u.nome }}</span>
<span class="u-email" title="{{ u.email }}">{{ u.email }}</span>
</div>
</div>
</td>
<td class="text-center">
<span class="badge-role">{{ u.permissao }}</span>
</td>
<td class="text-center">
<span class="status-dot" [class.off]="u.ativo === false" title="{{ u.ativo === false ? 'Inativo' : 'Ativo' }}"></span>
</td>
<td class="text-center">
<div class="actions-group">
<button type="button" class="btn-action edit" title="Editar" (click)="openEditUser(u); $event.stopPropagation()">
<i class="bi bi-pencil-fill"></i>
</button>
<button
type="button"
class="btn-action"
[class.edit]="u.ativo === false"
[class.delete]="u.ativo !== false"
[title]="u.ativo === false ? 'Reativar conta' : 'Inativar conta'"
(click)="confirmToggleUserStatus(u); $event.stopPropagation()">
<i class="bi" [class.bi-person-check-fill]="u.ativo === false" [class.bi-person-x-fill]="u.ativo !== false"></i>
</button>
<button
type="button"
class="btn-action delete"
[title]="u.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'"
(click)="confirmPermanentDeleteUser(u); $event.stopPropagation()">
<i class="bi bi-trash-fill"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="empty-state-list" *ngIf="!manageUsersLoading && !manageUsers.length">
<i class="bi bi-search"></i>
<p>Nenhum usuário encontrado.</p>
</div>
</div>
<div class="list-footer">
<div class="page-info">
{{ managePage }} / {{ manageTotalPages }}
</div>
<div class="pagination">
<button type="button" class="btn-ghost icon-only" (click)="manageGoToPage(managePage - 1)" [disabled]="managePage <= 1">
<i class="bi bi-chevron-left"></i>
</button>
<button type="button" class="btn-ghost icon-only" (click)="manageGoToPage(managePage + 1)" [disabled]="managePage >= manageTotalPages">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
<div class="manage-right-wrapper">
<div class="manage-right" *ngIf="editUserTarget as target">
<div class="edit-header-info">
<div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div>
<div class="info-text">
<h4>{{ target.nome }}</h4>
<span>Editando perfil</span>
</div>
</div>
<div class="form-alert error" *ngIf="editUserErrors.length">
<ul><li *ngFor="let err of editUserErrors">{{ err.message }}</li></ul>
</div>
<div class="form-alert success" *ngIf="editUserSuccess">{{ editUserSuccess }}</div>
<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>
<input id="editHeaderNome" type="text" formControlName="nome" />
</div>
</div>
<div class="form-field">
<label for="editHeaderEmail">Email Corporativo</label>
<input id="editHeaderEmail" type="email" formControlName="email" />
</div>
<div class="form-row two-col">
<div class="form-field">
<label for="editHeaderSenha">Nova Senha</label>
<input id="editHeaderSenha" type="password" placeholder="••••••" formControlName="senha" autocomplete="new-password" />
</div>
<div class="form-field">
<label for="editHeaderConfirmar">Confirmar</label>
<input id="editHeaderConfirmar" type="password" placeholder="••••••" formControlName="confirmarSenha" autocomplete="new-password" />
</div>
</div>
<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>
</div>
<div class="form-field">
<label class="mb-1 d-block">Status da Conta</label>
<div class="toggle-wrapper">
<label class="switch">
<input id="editHeaderAtivo" type="checkbox" formControlName="ativo" />
<span class="slider round"></span>
</label>
<span class="toggle-status" [class.active]="editUserForm.get('ativo')?.value">
{{ editUserForm.get('ativo')?.value ? 'Ativo' : 'Inativo' }}
</span>
</div>
</div>
</div>
</form>
<div class="manage-actions-footer">
<button
type="button"
class="btn-secondary btn-delete-permanent-left"
(click)="confirmPermanentDeleteUser(target)"
[disabled]="editUserSubmitting"
[title]="target.ativo !== false ? 'Inative a conta antes de excluir permanentemente' : 'Excluir permanentemente'">
Excluir Permanentemente
</button>
<button type="button" class="btn-ghost" (click)="cancelEditUser()" [disabled]="editUserSubmitting">Cancelar</button>
<button type="submit" form="editUserHeaderForm" class="btn-primary" [disabled]="editUserSubmitting || !editUserTarget">
<span *ngIf="!editUserSubmitting">Salvar Alterações</span>
<span *ngIf="editUserSubmitting"><div class="spinner-border spinner-border-sm"></div></span>
</button>
</div>
</div>
<div class="manage-right placeholder" *ngIf="!editUserTarget">
<div class="placeholder-content">
<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>
</div>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000;">
<div class="toast notification-toast" #notifToast>
<div class="toast-header">
<i class="bi bi-exclamation-circle-fill text-warning me-2"></i>
<strong class="me-auto">Atenção à Vigência</strong>
<button type="button" class="btn-close" (click)="acknowledgeCurrentToast()" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body" *ngIf="toastNotification as toastItem">
A linha <strong>{{ toastItem.linha }}</strong> vence em breve.
<div class="mt-2 pt-2 border-top">
<button type="button" class="btn btn-sm btn-primary w-100" (click)="acknowledgeNotification(toastItem)" data-bs-dismiss="toast">
Estou ciente
</button>
</div>
</div>
</div>
</div>
<div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div>
<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>
</a>
<button type="button" class="close-btn" (click)="closeMenu()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="side-menu-body">
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
</a>
<a *ngIf="canViewAll" routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<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()">
<i class="bi bi-table"></i> <span>Mureg</span>
<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 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 routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar-check"></i> <span>Vigência</span>
<a *ngIf="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
</a>
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de Número</span>
<a *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()">
<i class="bi bi-person-lines-fill"></i> <span>Dados dos Usuários</span>
<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>
<!-- ✅ CORRIGIDO + ESTILIZADO IGUAL AOS OUTROS -->
<a routerLink="/relatorios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-bar-chart-fill"></i> <span>Relatórios</span>
<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="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 *ngIf="canViewAll" routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a>
<a *ngIf="isSystemAdmin" routerLink="/system/fornecer-usuario" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-shield-lock-fill"></i> <span>Fornecer usuário</span>
</a>
</div>
</aside>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Header } from './header';
@ -8,7 +11,12 @@ describe('Header', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Header]
imports: [Header],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();

File diff suppressed because it is too large Load Diff

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 adminGuard: CanActivateFn = () => {
const router = inject(Router);
const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
if (!isPlatformBrowser(platformId)) {
// Em SSR não há storage do usuário para validar sessão/perfil.
return true;
}
const token = authService.token;
if (!token) {
return router.parseUrl('/login');
}
const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor');
if (!hasAccess) {
return router.parseUrl('/dashboard');
}
return true;
};

View File

@ -1,21 +1,32 @@
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 authGuard: CanActivateFn = () => {
const router = inject(Router);
const platformId = inject(PLATFORM_ID);
const authService = inject(AuthService);
// SSR: não existe localStorage. Bloqueia e manda pro login.
if (!isPlatformBrowser(platformId)) {
return router.parseUrl('/login');
// Em SSR não existe acesso ao storage do usuário.
// Deixa renderizar e valida no browser após hidratação.
return true;
}
const token = localStorage.getItem('token');
const token = authService.token;
if (!token) {
return router.parseUrl('/login');
}
const payload = authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
authService.logout();
return router.parseUrl('/login');
}
return true;
};

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 systemAdminGuard: 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 isSystemAdmin = authService.hasRole('sysadmin');
if (!isSystemAdmin) {
return router.parseUrl('/dashboard');
}
return true;
};

View File

@ -1,10 +1,13 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// ✅ SSR-safe
if (typeof window === 'undefined') return next(req);
const token = localStorage.getItem('token');
const authService = inject(AuthService);
const token = authService.token;
if (!token) return next(req);
return next(

View File

@ -0,0 +1,33 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { catchError, throwError } from 'rxjs';
import { SessionNoticeService } from '../services/session-notice.service';
import { AuthService } from '../services/auth.service';
export const sessionInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
if (!isPlatformBrowser(platformId)) {
return next(req);
}
const sessionNotice = inject(SessionNoticeService);
const authService = inject(AuthService);
const url = (req.url || '').toLowerCase();
const isAuthRequest = url.includes('/auth/login') || url.includes('/auth/register');
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err?.status === 401) {
// 401 durante /auth/login = credenciais inválidas, não "sessão expirada".
if (!isAuthRequest && !!authService.token) {
sessionNotice.handleUnauthorized();
}
} else if (err?.status === 403) {
sessionNotice.handleForbidden();
}
return throwError(() => err);
})
);
};

View File

@ -0,0 +1,755 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 12000;">
<div
class="toast border-0 shadow"
[class.show]="toastOpen"
[class.text-bg-success]="toastType === 'success'"
[class.text-bg-danger]="toastType === 'danger'"
>
<div class="toast-header border-bottom-0">
<strong class="me-auto">LineGestao</strong>
<button type="button" class="btn-close" (click)="toastOpen = false"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark fw-bold">{{ toastMessage }}</div>
</div>
</div>
<section class="chips-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>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-chips">
<div class="chips-card">
<!-- HEADER -->
<div class="chips-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-inboxes"></i> Gestão de Chips
</div>
<div class="header-title">
<h5 class="title mb-0">Chips Virgens e Recebidos</h5>
<small class="subtitle">Importação e acompanhamento</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button
*ngIf="isAdmin && activeTab === 'chips'"
class="btn btn-brand btn-sm"
(click)="openChipCreate()"
>
<i class="bi bi-plus-circle me-1"></i> Novo Chip
</button>
<button
*ngIf="isAdmin && activeTab === 'controle'"
class="btn btn-brand btn-sm"
(click)="openControleCreate()"
>
<i class="bi bi-plus-circle me-1"></i> Novo Recebimento
</button>
</div>
</div>
<div class="tab-row">
<button type="button" class="tab-btn" [class.active]="activeTab === 'chips'" (click)="setTab('chips')">
<i class="bi bi-sim"></i> Chips Virgens
</button>
<button type="button" class="tab-btn" [class.active]="activeTab === 'controle'" (click)="setTab('controle')">
<i class="bi bi-clipboard-data"></i> Controle Recebidos
</button>
</div>
<div class="filters-row" *ngIf="activeTab === 'controle'">
<div class="filter-item">
<app-select
class="select-glass"
size="sm"
[options]="anoOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="controleAno"
(ngModelChange)="onControleAnoChange()"
></app-select>
</div>
<div class="filter-tabs">
<button type="button" class="filter-tab" [class.active]="controleResumo === ''" (click)="setControleResumo('')">Todos</button>
<button type="button" class="filter-tab" [class.active]="controleResumo === 'false'" (click)="setControleResumo('false')">Detalhado</button>
<button type="button" class="filter-tab" [class.active]="controleResumo === 'true'" (click)="setControleResumo('true')">Resumo</button>
</div>
</div>
<div class="controls">
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i
class="bi"
[class.bi-search]="!chipsLoading && !controleLoading"
[class.bi-hourglass-split]="chipsLoading || controleLoading"
[class.text-brand]="chipsLoading || controleLoading"
></i>
</span>
<input
*ngIf="activeTab === 'chips'"
class="form-control"
placeholder="Pesquisar..."
[(ngModel)]="chipsSearch"
(ngModelChange)="onChipsSearch()"
/>
<input
*ngIf="activeTab === 'controle'"
class="form-control"
placeholder="Pesquisar..."
[(ngModel)]="controleSearch"
(ngModelChange)="onControleSearch()"
/>
<button
class="btn btn-outline-secondary btn-clear"
type="button"
(click)="activeTab === 'chips' ? clearChipsSearch() : clearControleSearch()"
*ngIf="chipsSearch || controleSearch"
>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<app-select
*ngIf="activeTab === 'chips'"
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="chipsPageSize"
(ngModelChange)="onChipsPageSizeChange()"
[disabled]="chipsLoading"
></app-select>
<app-select
*ngIf="activeTab === 'controle'"
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="controlePageSize"
(ngModelChange)="onControlePageSizeChange()"
[disabled]="controleLoading"
></app-select>
</div>
</div>
</div>
</div>
<!-- BODY (scroll interno do card) -->
<div class="chips-body">
<!-- CHIPS -->
<ng-container *ngIf="activeTab === 'chips'">
<div class="content-scroll groups-container">
<div class="text-center p-5" *ngIf="chipsLoading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!chipsLoading && chipsGroups.length === 0">
Nenhum registro encontrado.
</div>
<div class="group-list" *ngIf="!chipsLoading">
<div
*ngFor="let g of pagedChipsGroups"
class="group-card"
[class.expanded]="expandedGroupObservacao === g.observacao"
>
<div class="group-header" (click)="toggleGroup(g.observacao)">
<div class="group-info">
<h6 class="group-title">{{ g.observacao }}</h6>
<div class="group-badges">
<span class="badge-pill">{{ g.total }} Registros</span>
</div>
</div>
<div class="group-toggle-icon">
<i class="bi bi-chevron-down"></i>
</div>
</div>
<div class="group-body-content" *ngIf="expandedGroupObservacao === g.observacao">
<div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>NÚMERO DO CHIP</th>
<th>OBSERVAÇÕES</th>
<th class="actions-col">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of g.items">
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="font-monospace text-brand">{{ display(r.numeroDoChip) }}</td>
<td class="text-start td-clip" [title]="display(r.observacoes)">{{ display(r.observacoes) }}</td>
<td>
<div class="action-group justify-content-center">
<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">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openChipDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="table-pagination" *ngIf="!chipsLoading && chipsGroups.length > 0">
<div class="page-info">
Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="activePage === 1">
<button class="page-link" (click)="goToPage(activePage - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of activePageNumbers" [class.active]="p === activePage">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="activePage === activeTotalPages">
<button class="page-link" (click)="goToPage(activePage + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</ng-container>
<!-- CONTROLE -->
<ng-container *ngIf="activeTab === 'controle'">
<div class="content-scroll">
<div class="text-center p-5" *ngIf="controleLoading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!controleLoading && controleGroups.length === 0">
Nenhum registro encontrado.
</div>
<div class="group-list" *ngIf="!controleLoading">
<div
*ngFor="let g of pagedControleGroups"
class="group-card"
[class.expanded]="expandedControleConteudo === g.conteudo"
>
<div class="group-header" (click)="toggleControleGroup(g.conteudo)">
<div class="group-info">
<h6 class="group-title">{{ g.conteudo }}</h6>
<div class="group-badges">
<span class="badge-pill">{{ g.total }} Registros</span>
</div>
</div>
<div class="group-toggle-icon">
<i class="bi bi-chevron-down"></i>
</div>
</div>
<div class="group-body-content" *ngIf="expandedControleConteudo === g.conteudo">
<div class="table-wrap inner-table-wrap">
<ng-container *ngIf="getResumoItems(g.items) as resumoItems">
<div class="table-section" *ngIf="resumoItems.length > 0">
<div class="section-label">Resumo</div>
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ANO</th>
<th>NOTA FISCAL</th>
<th>DATA DA NF</th>
<th>QTD.</th>
<th>CONTEÚDO DA NF</th>
<th>DATA DO RECEBIMENTO</th>
<th class="actions-col">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of resumoItems">
<td class="text-muted fw-bold">{{ display(r.ano) }}</td>
<td>{{ display(r.notaFiscal) }}</td>
<td>{{ formatDate(r.dataDaNf) }}</td>
<td class="fw-bold">{{ display(r.quantidade) }}</td>
<td class="text-start td-clip" [title]="display(r.conteudoDaNf)">{{ display(r.conteudoDaNf) }}</td>
<td>{{ formatDate(r.dataDoRecebimento) }}</td>
<td>
<div class="action-group justify-content-center">
<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">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
<ng-container *ngIf="getDetalheItems(g.items) as detalheItems">
<div class="table-section" *ngIf="detalheItems.length > 0">
<div class="section-label">Detalhe</div>
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ANO</th>
<th>NOTA FISCAL</th>
<th>CHIP</th>
<th>SERIAL</th>
<th>NÚMERO DA LINHA</th>
<th>VALOR UNIT.</th>
<th>VALOR DA NF</th>
<th class="actions-col">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of detalheItems">
<td class="text-muted fw-bold">{{ display(r.ano) }}</td>
<td>{{ display(r.notaFiscal) }}</td>
<td class="font-monospace">{{ display(r.chip) }}</td>
<td class="font-monospace">{{ display(r.serial) }}</td>
<td class="font-monospace">{{ display(r.numeroDaLinha) }}</td>
<td class="text-end fw-bold">{{ formatMoney(r.valorUnit) }}</td>
<td class="text-end fw-bold">{{ formatMoney(r.valorDaNf) }}</td>
<td>
<div class="action-group justify-content-center">
<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">
<i class="bi bi-pencil-square"></i>
</button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openControleDelete(r); $event.stopPropagation()" title="Excluir">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
</div>
</div>
</div>
</div>
<div class="table-pagination" *ngIf="!controleLoading && controleGroups.length > 0">
<div class="page-info">
Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="activePage === 1">
<button class="page-link" (click)="goToPage(activePage - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of activePageNumbers" [class.active]="p === activePage">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="activePage === activeTotalPages">
<button class="page-link" (click)="goToPage(activePage + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</ng-container>
</div>
</div>
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen || chipEditOpen || chipDeleteOpen || controleEditOpen || controleDeleteOpen || chipCreateOpen || controleCreateOpen" (click)="closeChipDetail(); closeControleDetail(); closeChipEdit(); cancelChipDelete(); closeControleEdit(); cancelControleDelete(); closeChipCreate(); closeControleCreate()"></div>
<!-- MODAL CHIP -->
<div class="modal-custom" *ngIf="chipDetailOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-sim"></i></span>
Detalhes do Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="chipDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!chipDetailLoading && chipDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações do Chip</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(chipDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Número do Chip</span>
<span class="val text-brand font-monospace">{{ display(chipDetailData.numeroDoChip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Observações</span>
<span class="val">{{ display(chipDetailData.observacoes) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL CONTROLE -->
<div class="modal-custom" *ngIf="controleDetailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-clipboard-data"></i></span>
Detalhes do Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="controleDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!controleDetailLoading && controleDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da NF</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Ano</span>
<span class="val">{{ display(controleDetailData.ano) }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(controleDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Nota Fiscal</span>
<span class="val">{{ display(controleDetailData.notaFiscal) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Chip</span>
<span class="val font-monospace">{{ display(controleDetailData.chip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Serial</span>
<span class="val font-monospace">{{ display(controleDetailData.serial) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Conteúdo da NF</span>
<span class="val">{{ display(controleDetailData.conteudoDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Número da Linha</span>
<span class="val font-monospace">{{ display(controleDetailData.numeroDaLinha) }}</span>
</div>
<div class="info-item">
<span class="lbl">Quantidade</span>
<span class="val">{{ display(controleDetailData.quantidade) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor Unit</span>
<span class="val">{{ formatMoney(controleDetailData.valorUnit) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor da NF</span>
<span class="val text-brand">{{ formatMoney(controleDetailData.valorDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Data da NF</span>
<span class="val">{{ formatDate(controleDetailData.dataDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Recebimento</span>
<span class="val">{{ formatDate(controleDetailData.dataDoRecebimento) }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ isResumo(controleDetailData) ? "RESUMO" : "DETALHE" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL CHIP CREATE -->
<div class="modal-custom" *ngIf="chipCreateOpen">
<div class="modal-card modal-lg create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Informações do Chip</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"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipCreateModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipCreateModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="chipCreateSaving" (click)="saveChipCreate()">
{{ chipCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE CREATE -->
<div class="modal-custom" *ngIf="controleCreateOpen">
<div class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleCreateModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-card-list me-2"></i> Dados da Nota</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"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.notaFiscal" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.conteudoDaNf" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.serial" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.chip" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleCreateModel.numeroDaLinha" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-currency-exchange me-2"></i> Valores e Datas</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"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorUnit" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.quantidade" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Valor da NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleCreateModel.valorDaNf" (ngModelChange)="onControleCreateValueChange()" /></div>
<div class="form-field"><label>Data da NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateDataNf" (ngModelChange)="onControleCreateDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleCreateRecebimento" /></div>
<div class="form-field"><label>Resumo</label><input class="form-check-input ms-2" type="checkbox" [(ngModel)]="controleCreateModel.isResumo" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="controleCreateSaving" (click)="saveControleCreate()">
{{ controleCreateSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP EDIT -->
<div class="modal-custom" *ngIf="chipEditOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="chipEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-sim me-2"></i> Identificação do Chip</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"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="chipEditModel.item" /></div>
<div class="form-field span-2"><label>Número do Chip</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.numeroDoChip" /></div>
<div class="form-field span-2"><label>Observações</label><input class="form-control form-control-sm" [(ngModel)]="chipEditModel.observacoes" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeChipEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="chipEditSaving" (click)="saveChipEdit()">
{{ chipEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CHIP DELETE -->
<div class="modal-custom" *ngIf="chipDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Chip
</div>
<button class="btn btn-sm btn-icon" (click)="cancelChipDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o chip <strong>{{ chipDeleteTarget?.numeroDoChip }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelChipDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmChipDelete()">Excluir</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE EDIT -->
<div class="modal-custom" *ngIf="controleEditOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="controleEditModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-receipt-cutoff me-2"></i> Documento</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"><label>Ano</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.ano" /></div>
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.item" /></div>
<div class="form-field span-2"><label>Nota Fiscal</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.notaFiscal" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.chip" /></div>
<div class="form-field"><label>Serial</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.serial" /></div>
<div class="form-field span-2"><label>Conteúdo da NF</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.conteudoDaNf" /></div>
<div class="form-field"><label>Número da Linha</label><input class="form-control form-control-sm" [(ngModel)]="controleEditModel.numeroDaLinha" /></div>
<div class="form-field"><label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="controleEditModel.isResumo">
<option [ngValue]="false">DETALHE</option>
<option [ngValue]="true">RESUMO</option>
</select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-cash-coin me-2"></i> Valores e Datas</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"><label>Quantidade</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.quantidade" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor Unit</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorUnit" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Valor NF</label><input class="form-control form-control-sm" type="number" [(ngModel)]="controleEditModel.valorDaNf" (ngModelChange)="onControleEditValueChange()" /></div>
<div class="form-field"><label>Data NF</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditDataNf" (ngModelChange)="onControleEditDateChange()" /></div>
<div class="form-field"><label>Recebimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="controleEditRecebimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeControleEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="controleEditSaving" (click)="saveControleEdit()">
{{ controleEditSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- MODAL CONTROLE DELETE -->
<div class="modal-custom" *ngIf="controleDeleteOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelControleDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover a NF <strong>{{ controleDeleteTarget?.notaFiscal }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelControleDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmControleDelete()">Excluir</button>
</div>
</div>
</div>

View File

@ -0,0 +1,833 @@
/* ========================================================== */
/* VARIÁVEIS E BASE */
/* ========================================================== */
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754;
--warn-bg: rgba(255, 193, 7, 0.15);
--warn-text: #b58100;
--radius-xl: 22px;
--radius-lg: 16px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.10);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
/* fallback: garante que o footer global não apareça nesta rota */
:host ::ng-deep app-footer {
display: none !important;
}
/* ========================================================== */
/* LAYOUT PRINCIPAL (travado para não aparecer footer global) */
/* ========================================================== */
.chips-page {
min-height: 100vh; /* ✅ igual Mureg: ocupa 100% da tela */
overflow-y: auto;
padding: 0 12px;
display: flex;
align-items: flex-start;
justify-content: center;
position: relative;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 255, 255, 0.25);
}
}
.page-blob {
position: fixed;
pointer-events: none;
border-radius: 999px;
filter: blur(34px);
opacity: 0.55;
z-index: 0;
background: radial-gradient(circle at 30% 30%, rgba(227,61,207,0.55), rgba(227,61,207,0.06));
animation: floaty 10s ease-in-out infinite;
&.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; }
&.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; }
&.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; }
&.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: .45; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
.container-chips {
width: 100%;
max-width: 1240px;
position: relative;
z-index: 1;
margin: 40px auto 24px; /* ✅ garante centralização horizontal */
display: flex;
min-height: 0;
}
/* ========================================================== */
/* CARD PRINCIPAL */
/* ========================================================== */
.chips-card {
width: 100%;
border-radius: var(--radius-xl);
overflow-y: auto;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
height: auto; /* ✅ ocupa a altura útil (igual sensação do Mureg) */
min-height: 70vh;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: calc(var(--radius-xl) - 1px);
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.65);
opacity: 0.75;
}
}
.chips-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2));
flex-shrink: 0;
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
text-align: center;
.title-badge { justify-self: center; margin-bottom: 8px; }
.header-actions { justify-self: center; }
}
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(227, 61, 207, 0.22);
backdrop-filter: blur(10px);
color: var(--text);
font-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.title {
font-size: 24px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
margin-bottom: 0;
}
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions {
justify-self: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
white-space: nowrap;
}
}
/* ========================================================== */
/* TABS E FILTROS */
/* ========================================================== */
.tab-row { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.tab-btn {
border: 1px solid rgba(17, 18, 20, 0.1);
background: rgba(255, 255, 255, 0.5);
color: var(--muted);
padding: 8px 16px;
border-radius: 12px;
font-weight: 800;
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
&:hover { color: var(--text); background: #fff; }
&.active {
color: var(--brand);
border-color: rgba(227, 61, 207, 0.35);
background: #fff;
box-shadow: 0 6px 16px rgba(227, 61, 207, 0.15);
}
}
.controls {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-top: 16px;
}
/* Pesquisa */
.search-group {
max-width: 270px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: stretch;
background: #fff;
border: 1px solid rgba(17, 18, 20, 0.15);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
&:focus-within {
border-color: var(--brand);
box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15);
transform: translateY(-1px);
}
.input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; }
.form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &:focus { outline: none; } }
.btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; cursor: pointer; &:hover { color: #dc3545; } }
}
/* Filtros */
.filters-row { display: flex; gap: 16px; align-items: center; margin-top: 12px; justify-content: center; }
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; }
.filter-tab {
border: none; background: transparent; padding: 6px 12px; border-radius: 8px; font-size: 0.8rem; font-weight: 800; color: var(--muted); transition: all 0.2s;
&.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); }
}
@media (min-width: 701px) {
.filters-row .filter-item .select-wrapper,
.filters-row .filter-item app-select.select-glass {
width: clamp(112px, 8vw, 148px);
}
}
@media (max-width: 768px) {
.tab-row {
margin-top: 12px;
gap: 6px;
align-items: stretch;
}
.tab-btn {
flex: 1 1 0;
min-width: 0;
justify-content: center;
padding: 8px 10px;
gap: 6px;
line-height: 1.08;
text-align: center;
white-space: normal;
text-wrap: balance;
}
.tab-btn i {
flex: 0 0 auto;
font-size: 0.95rem;
}
}
@media (max-width: 700px) {
.filters-row {
gap: 10px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.filters-row .filter-item {
width: auto;
min-width: 0;
display: flex;
justify-content: center;
}
.filters-row .filter-item .select-wrapper,
.filters-row .filter-item app-select.select-glass {
width: clamp(112px, 34vw, 148px);
max-width: calc(100vw - 32px);
}
.filters-row .filter-tabs {
width: fit-content;
max-width: calc(100vw - 32px);
justify-content: center;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
padding: 4px;
margin-inline: auto;
align-self: center;
}
.filters-row .filter-tabs .filter-tab {
flex: 0 0 auto;
white-space: nowrap;
padding: 5px 8px;
font-size: 0.76rem;
border-radius: 7px;
}
}
/* Select */
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
.select-glass {
appearance: none;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(17, 18, 20, 0.15);
border-radius: 12px;
color: var(--blue);
font-weight: 800;
font-size: 0.9rem;
padding: 8px 36px 8px 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover { background: #fff; border-color: var(--blue); }
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
filter: brightness(1.05);
}
}
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(3, 15, 170, 0.24);
color: var(--blue);
transition: all 0.2s ease;
&:hover {
background: #fff;
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
}
}
/* ========================================================== */
/* BODY (scroll interno igual Mureg) */
/* ========================================================== */
.chips-body {
padding: 0;
background: transparent;
flex: 1;
min-height: 70vh;
overflow: visible;
display: flex;
flex-direction: column;
}
.content-scroll {
padding: 16px;
overflow: visible;
height: auto;
flex: 1;
min-height: 0;
}
/* Lists / Groups */
.group-list { display: flex; flex-direction: column; gap: 12px; }
.group-card {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(17, 18, 20, 0.08);
overflow: hidden;
transition: all 0.3s ease;
&:hover { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.1); }
&.expanded { border-color: var(--brand); box-shadow: 0 8px 24px rgba(227, 61, 207, 0.12); }
}
.group-header {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background: linear-gradient(180deg, #fff, #fdfdfd);
&:hover .group-toggle-icon { color: var(--brand); }
}
.group-info { display: flex; flex-direction: column; gap: 6px; }
.group-title { margin: 0; font-weight: 800; color: var(--text); font-size: 1rem; }
.group-badges { display: flex; gap: 8px; }
.badge-pill {
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 999px;
font-weight: 800;
text-transform: uppercase;
background: rgba(3, 15, 170, 0.1);
color: var(--blue);
}
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
.group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
.group-body-content {
border-top: 1px solid rgba(17, 18, 20, 0.06);
background: #fbfbfc;
animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1);
padding: 0;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.empty-group {
background: rgba(255,255,255,0.7);
border: 1px dashed rgba(17,18,20,0.12);
border-radius: 16px;
padding: 18px;
text-align: center;
font-weight: 800;
color: var(--muted);
}
/* Table */
.table-wrap { overflow-x: auto; overflow-y: visible; height: auto; min-height: 0; }
.inner-table-wrap { max-height: none; }
.table-section { padding: 6px 10px 12px; }
.table-section + .table-section { border-top: 1px dashed rgba(17, 18, 20, 0.12); margin-top: 8px; }
.section-label {
font-size: 0.7rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 8px 6px;
}
.table-modern {
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;
color: rgba(17, 18, 20, 0.7);
font-size: 0.8rem;
font-weight: 950;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
cursor: pointer;
text-align: center !important;
&:hover { color: var(--brand); }
}
tbody tr {
transition: background-color 0.2s;
border-bottom: 1px solid rgba(17, 18, 20, 0.05);
&:hover { background-color: rgba(227, 61, 207, 0.05); }
td { border-bottom: 1px solid rgba(17, 18, 20, 0.04); }
}
td {
padding: 12px;
vertical-align: middle;
white-space: nowrap;
font-size: 0.875rem;
color: var(--text);
text-align: center !important;
}
}
.sort-caret { font-size: 0.75rem; color: rgba(17, 18, 20, 0.35); &.active { color: var(--brand); } }
.th-content { display: inline-flex; align-items: center; gap: 6px; justify-content: center; }
.text-brand { color: var(--brand) !important; }
.font-monospace { font-family: 'JetBrains Mono', monospace; letter-spacing: -0.5px; }
.td-clip { max-width: 260px; overflow-y: auto; text-overflow: ellipsis; }
.row-clickable { cursor: pointer; }
.actions-col { min-width: 152px; }
/* Paginação interna */
.table-pagination {
padding: 12px 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.page-info {
font-weight: 800;
color: var(--muted);
}
/* Ações na tabela (estilo Mureg) */
.action-group {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.action-group .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); }
&.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); }
&.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
}
/* ========================================================== */
/* FOOTER interno (igual Mureg) */
/* ========================================================== */
.chips-footer {
padding: 14px 24px;
border-top: 1px solid rgba(17, 18, 20, 0.06);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
@media (max-width: 768px) {
justify-content: center;
text-align: center;
}
}
.pagination-modern .page-link {
color: var(--blue);
font-weight: 900;
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.1);
background: rgba(255,255,255,0.6);
margin: 0 2px;
&:hover { transform: translateY(-1px); border-color: var(--brand); color: var(--brand); }
}
.pagination-modern .page-item.active .page-link {
background-color: var(--blue);
border-color: var(--blue);
color: #fff;
}
/* ========================================================== */
/* MODAIS (mantidos) */
/* ========================================================== */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; }
.modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header {
padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff;
display: flex; justify-content: space-between; align-items: center;
.modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; background: rgba(3, 15, 170, 0.1); color: var(--blue);
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
&.success { background: var(--success-bg); color: var(--success-text); }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
}
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } }
}
.modal-body { padding: 20px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
.modal-card.create-modal .edit-sections { gap: 14px; }
.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
.modal-card.create-modal .form-control,
.modal-card.create-modal .form-select { min-height: 40px; }
.modal-card.create-modal .form-check-input {
width: 1.05rem;
height: 1.05rem;
border-color: rgba(17, 18, 20, 0.32);
&:focus { box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); }
&:checked { background-color: var(--brand); border-color: var(--brand); }
}
.modal-card.create-modal .modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 14px 20px !important;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
}
.modal-card.create-modal .modal-footer .btn {
border-radius: 12px;
font-weight: 900;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
.modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
.modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
}
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow-y: auto; height: auto; display: flex; flex-direction: column; }
div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
div.box-body { padding: 16px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0; }
.info-item {
display: flex; flex-direction: column; align-items: center; text-align: center;
padding: 6px 8px; background: rgba(245, 245, 247, 0.5); border-radius: 10px; border: 1px solid rgba(0,0,0,0.03);
&.span-2 { grid-column: span 2; }
.lbl { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; }
.val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2; }
}
.edit-sections { display: grid; gap: 12px; }
.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
summary.box-header {
cursor: pointer;
user-select: none;
list-style: none;
padding: 10px 16px;
font-size: 0.8rem;
font-weight: 800;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
background: #fdfdfd;
display: flex;
align-items: center;
border-radius: 14px 14px 0 0;
transition: background 0.2s ease, color 0.2s ease;
span {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
}
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
&::-webkit-details-marker { display: none; }
}
.modal-card.create-modal summary.box-header {
padding: 11px 14px;
border-bottom-color: rgba(227, 61, 207, 0.08);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.10), rgba(3, 15, 170, 0.06));
span {
font-size: 0.76rem;
line-height: 1.2;
color: rgba(17, 18, 20, 0.84);
letter-spacing: 0.04em;
font-weight: 900;
}
i:not(.transition-icon) {
width: 22px;
height: 22px;
margin-right: 0;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(227, 61, 207, 0.12);
color: var(--brand);
flex-shrink: 0;
}
}
.transition-icon { color: var(--muted); transition: transform 0.25s ease, color 0.25s ease; }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
&.span-2 { grid-column: span 2; }
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.64);
}
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.15);
background: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.36); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}

View File

@ -0,0 +1,799 @@
import { Component, Inject, PLATFORM_ID, OnInit, OnDestroy } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir, UpdateChipVirgemRequest, UpdateControleRecebidoRequest, CreateChipVirgemRequest, CreateControleRecebidoRequest } from '../../services/chips-controle.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
// Interface para o Agrupamento
interface ChipGroup {
observacao: string;
total: number;
items: ChipVirgemListDto[];
}
interface ControleGroup {
conteudo: string;
total: number;
items: ControleRecebidoListDto[];
}
interface ChipVirgemCreateModel {
id: string;
item: number | null;
numeroDoChip: string | null;
observacoes: string | null;
}
type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes';
type ControleSortKey =
| 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha'
| 'valorUnit' | 'valorDaNf' | 'dataDaNf' | 'dataDoRecebimento' | 'quantidade' | 'isResumo';
@Component({
selector: 'app-chips-controle-recebidos',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './chips-controle-recebidos.html',
styleUrls: ['./chips-controle-recebidos.scss']
})
export class ChipsControleRecebidos implements OnInit, OnDestroy {
activeTab: 'chips' | 'controle' = 'chips';
// --- Chips ---
chipsRows: ChipVirgemListDto[] = [];
chipsGroups: ChipGroup[] = [];
pagedChipsGroups: ChipGroup[] = [];
expandedGroupObservacao: string | null = null;
chipsLoading = false;
chipsSearch = '';
chipsPage = 1;
chipsPageSize = 10;
chipsTotal = 0;
chipsSortBy: ChipsSortKey = 'item';
chipsSortDir: SortDir = 'asc';
private chipsSearchTimer: any = null;
// --- Controle ---
controleRows: ControleRecebidoListDto[] = [];
controleGroups: ControleGroup[] = [];
pagedControleGroups: ControleGroup[] = [];
expandedControleConteudo: string | null = null;
controleLoading = false;
controleSearch = '';
controlePage = 1;
controlePageSize = 10;
controleTotal = 0;
controleSortBy: ControleSortKey = 'ano';
controleSortDir: SortDir = 'desc';
controleAno: number | '' = '';
controleResumo: '' | 'true' | 'false' = '';
private controleSearchTimer: any = null;
// --- Opções ---
pageSizeOptions = [10, 20, 50, 100];
anoOptions = [
{ label: 'Todos', value: '' },
{ label: '2022', value: 2022 },
{ label: '2023', value: 2023 },
{ label: '2024', value: 2024 },
{ label: '2025', value: 2025 }
];
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: any = null;
chipDetailOpen = false;
chipDetailLoading = false;
chipDetailData: ChipVirgemListDto | null = null;
chipCreateOpen = false;
chipCreateSaving = false;
chipCreateModel: ChipVirgemCreateModel | null = null;
chipEditOpen = false;
chipEditSaving = false;
chipEditModel: ChipVirgemListDto | null = null;
chipEditingId: string | null = null;
chipDeleteOpen = false;
chipDeleteTarget: ChipVirgemListDto | null = null;
controleDetailOpen = false;
controleDetailLoading = false;
controleDetailData: ControleRecebidoListDto | null = null;
controleCreateOpen = false;
controleCreateSaving = false;
controleCreateModel: ControleRecebidoListDto | null = null;
controleCreateDataNf = '';
controleCreateRecebimento = '';
controleEditOpen = false;
controleEditSaving = false;
controleEditModel: ControleRecebidoListDto | null = null;
controleEditDataNf = '';
controleEditRecebimento = '';
controleEditingId: string | null = null;
controleDeleteOpen = false;
controleDeleteTarget: ControleRecebidoListDto | null = null;
isAdmin = false;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private service: ChipsControleService,
private http: HttpClient,
private authService: AuthService
) {}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.isAdmin = this.authService.hasRole('sysadmin');
this.fetchChips();
this.fetchControle();
}
ngOnDestroy(): void {
if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer);
if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
}
setTab(tab: 'chips' | 'controle') {
this.activeTab = tab;
if (tab === 'chips') {
this.expandedGroupObservacao = null;
this.applyChipsPagination();
this.closeControleDetail();
} else {
this.expandedControleConteudo = null;
this.applyControlePagination();
this.closeChipDetail();
}
}
// =====================
// Chips Virgens
// =====================
fetchChips() {
this.chipsLoading = true;
this.service.getChipsVirgens({
search: this.chipsSearch,
page: 1,
pageSize: 5000,
sortBy: this.chipsSortBy,
sortDir: this.chipsSortDir
}).subscribe({
next: (res) => {
const items = (res as any)?.items ?? [];
this.chipsRows = items.map((x: any, idx: number) => this.normalizeChip(x, idx));
this.buildChipsGroups();
this.chipsTotal = this.chipsGroups.length;
this.applyChipsPagination();
this.chipsLoading = false;
},
error: () => {
this.chipsLoading = false;
this.showToast('Erro ao carregar Chips Virgens.', 'danger');
}
});
}
private buildChipsGroups() {
const groupsMap = new Map<string, ChipVirgemListDto[]>();
this.chipsRows.forEach(row => {
const key = row.observacoes && row.observacoes.trim() !== ''
? row.observacoes.trim()
: '(Sem Observações)';
if (!groupsMap.has(key)) groupsMap.set(key, []);
groupsMap.get(key)?.push(row);
});
this.chipsGroups = [];
groupsMap.forEach((items, key) => {
this.chipsGroups.push({ observacao: key, total: items.length, items });
});
this.chipsGroups.sort((a, b) => a.observacao.localeCompare(b.observacao));
this.expandedGroupObservacao = null;
}
private applyChipsPagination() {
const start = (this.chipsPage - 1) * this.chipsPageSize;
const end = start + this.chipsPageSize;
this.pagedChipsGroups = this.chipsGroups.slice(start, end);
if (this.expandedGroupObservacao && !this.pagedChipsGroups.some(g => g.observacao === this.expandedGroupObservacao)) {
this.expandedGroupObservacao = null;
}
}
toggleGroup(obs: string) {
this.expandedGroupObservacao = this.expandedGroupObservacao === obs ? null : obs;
}
openChipDetail(row: ChipVirgemListDto) {
if (!row?.id) return;
this.chipDetailOpen = true;
this.chipDetailLoading = true;
this.chipDetailData = null;
this.service.getChipVirgemById(row.id).subscribe({
next: (data) => {
this.chipDetailData = data ?? row;
this.chipDetailLoading = false;
},
error: () => {
this.chipDetailLoading = false;
this.chipDetailData = row;
}
});
}
openChipCreate() {
if (!this.isAdmin) return;
this.chipCreateModel = {
id: '',
item: null,
numeroDoChip: '',
observacoes: ''
};
this.chipCreateOpen = true;
this.chipCreateSaving = false;
}
closeChipCreate() {
this.chipCreateOpen = false;
this.chipCreateSaving = false;
this.chipCreateModel = null;
}
saveChipCreate() {
if (!this.chipCreateModel) return;
this.chipCreateSaving = true;
const payload: CreateChipVirgemRequest = {
item: this.toNullableNumber(this.chipCreateModel.item),
numeroDoChip: this.chipCreateModel.numeroDoChip,
observacoes: this.chipCreateModel.observacoes
};
this.service.createChipVirgem(payload).subscribe({
next: () => {
this.chipCreateSaving = false;
this.closeChipCreate();
this.fetchChips();
this.showToast('Chip criado com sucesso!', 'success');
},
error: () => {
this.chipCreateSaving = false;
this.showToast('Erro ao criar chip.', 'danger');
}
});
}
openChipEdit(row: ChipVirgemListDto) {
if (!this.isAdmin) return;
this.service.getChipVirgemById(row.id).subscribe({
next: (data) => {
this.chipEditingId = data.id;
this.chipEditModel = { ...data };
this.chipEditOpen = true;
},
error: () => this.showToast('Erro ao abrir edição.', 'danger')
});
}
closeChipEdit() {
this.chipEditOpen = false;
this.chipEditSaving = false;
this.chipEditModel = null;
this.chipEditingId = null;
}
saveChipEdit() {
if (!this.chipEditModel || !this.chipEditingId) return;
this.chipEditSaving = true;
const payload: UpdateChipVirgemRequest = {
item: this.toNullableNumber(this.chipEditModel.item),
numeroDoChip: this.chipEditModel.numeroDoChip,
observacoes: this.chipEditModel.observacoes
};
this.service.updateChipVirgem(this.chipEditingId, payload).subscribe({
next: () => {
this.chipEditSaving = false;
this.closeChipEdit();
this.fetchChips();
this.showToast('Chip atualizado!', 'success');
},
error: () => {
this.chipEditSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
openChipDelete(row: ChipVirgemListDto) {
if (!this.isAdmin) return;
this.chipDeleteTarget = row;
this.chipDeleteOpen = true;
}
cancelChipDelete() {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
}
async confirmChipDelete() {
if (!this.chipDeleteTarget) return;
if (!(await confirmDeletionWithTyping('este chip virgem'))) return;
const id = this.chipDeleteTarget.id;
this.service.removeChipVirgem(id).subscribe({
next: () => {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
this.fetchChips();
this.showToast('Chip removido.', 'success');
},
error: () => {
this.chipDeleteOpen = false;
this.chipDeleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
closeChipDetail() {
this.chipDetailOpen = false;
this.chipDetailLoading = false;
this.chipDetailData = null;
}
onChipsSearch() {
if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer);
this.chipsSearchTimer = setTimeout(() => {
this.chipsPage = 1;
this.fetchChips();
}, 300);
}
clearChipsSearch() {
this.chipsSearch = '';
this.chipsPage = 1;
this.fetchChips();
}
onChipsPageSizeChange() {
this.chipsPage = 1;
this.applyChipsPagination();
}
// =====================
// Controle Recebidos
// =====================
fetchControle() {
this.controleLoading = true;
this.service.getControleRecebidos({
search: this.controleSearch,
page: 1,
pageSize: 5000,
sortBy: this.controleSortBy,
sortDir: this.controleSortDir,
ano: this.controleAno,
isResumo: this.controleResumo
}).subscribe({
next: (res) => {
const items = (res as any)?.items ?? [];
this.controleRows = items.map((x: any, idx: number) => this.normalizeControle(x, idx));
this.buildControleGroups();
this.controleTotal = this.controleGroups.length;
this.applyControlePagination();
this.controleLoading = false;
},
error: () => {
this.controleLoading = false;
this.showToast('Erro ao carregar Controle.', 'danger');
}
});
}
onControleSearch() {
if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer);
this.controleSearchTimer = setTimeout(() => {
this.controlePage = 1;
this.fetchControle();
}, 300);
}
clearControleSearch() {
this.controleSearch = '';
this.controlePage = 1;
this.fetchControle();
}
setControleSort(key: ControleSortKey) {
if (this.controleSortBy === key) {
this.controleSortDir = this.controleSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.controleSortBy = key;
this.controleSortDir = 'asc';
}
this.controlePage = 1;
this.fetchControle();
}
onControlePageSizeChange() {
this.controlePage = 1;
this.applyControlePagination();
}
onControleAnoChange() {
this.controlePage = 1;
this.fetchControle();
}
setControleResumo(val: '' | 'true' | 'false') {
this.controleResumo = val;
this.controlePage = 1;
this.fetchControle();
}
private buildControleGroups() {
const groupsMap = new Map<string, ControleRecebidoListDto[]>();
this.controleRows.forEach(row => {
const key = row.conteudoDaNf && row.conteudoDaNf.trim() !== ''
? row.conteudoDaNf.trim()
: '(Sem Conteúdo)';
if (!groupsMap.has(key)) groupsMap.set(key, []);
groupsMap.get(key)?.push(row);
});
this.controleGroups = [];
groupsMap.forEach((items, key) => {
this.controleGroups.push({ conteudo: key, total: items.length, items });
});
this.controleGroups.sort((a, b) => a.conteudo.localeCompare(b.conteudo));
this.expandedControleConteudo = null;
}
private applyControlePagination() {
const start = (this.controlePage - 1) * this.controlePageSize;
const end = start + this.controlePageSize;
this.pagedControleGroups = this.controleGroups.slice(start, end);
if (this.expandedControleConteudo && !this.pagedControleGroups.some(g => g.conteudo === this.expandedControleConteudo)) {
this.expandedControleConteudo = null;
}
}
toggleControleGroup(conteudo: string) {
this.expandedControleConteudo = this.expandedControleConteudo === conteudo ? null : conteudo;
}
openControleDetail(row: ControleRecebidoListDto) {
if (!row?.id) return;
this.controleDetailOpen = true;
this.controleDetailLoading = true;
this.controleDetailData = null;
this.service.getControleRecebidoById(row.id).subscribe({
next: (data) => {
this.controleDetailData = data ?? row;
this.controleDetailLoading = false;
},
error: () => {
this.controleDetailLoading = false;
this.controleDetailData = row;
}
});
}
openControleCreate() {
if (!this.isAdmin) return;
this.controleCreateModel = {
id: '',
ano: new Date().getFullYear(),
item: null,
notaFiscal: '',
chip: '',
serial: '',
conteudoDaNf: '',
numeroDaLinha: '',
valorUnit: null,
valorDaNf: null,
dataDaNf: null,
dataDoRecebimento: null,
quantidade: null,
isResumo: false
} as ControleRecebidoListDto;
this.controleCreateDataNf = '';
this.controleCreateRecebimento = '';
this.controleCreateOpen = true;
this.controleCreateSaving = false;
}
closeControleCreate() {
this.controleCreateOpen = false;
this.controleCreateSaving = false;
this.controleCreateModel = null;
this.controleCreateDataNf = '';
this.controleCreateRecebimento = '';
}
onControleCreateValueChange() {
if (!this.controleCreateModel) return;
this.recalculateControleTotals(this.controleCreateModel);
}
onControleEditValueChange() {
if (!this.controleEditModel) return;
this.recalculateControleTotals(this.controleEditModel);
}
onControleCreateDateChange() {
if (!this.controleCreateModel) return;
if (!this.controleCreateModel.ano && this.controleCreateDataNf) {
const year = new Date(this.controleCreateDataNf).getFullYear();
if (Number.isFinite(year)) this.controleCreateModel.ano = year;
}
}
onControleEditDateChange() {
if (!this.controleEditModel) return;
if (!this.controleEditModel.ano && this.controleEditDataNf) {
const year = new Date(this.controleEditDataNf).getFullYear();
if (Number.isFinite(year)) this.controleEditModel.ano = year;
}
}
private recalculateControleTotals(model: ControleRecebidoListDto) {
const quantidade = this.toNullableNumber(model.quantidade);
const valorUnit = this.toNullableNumber(model.valorUnit);
const valorDaNf = this.toNullableNumber(model.valorDaNf);
if (quantidade != null && valorUnit != null && (valorDaNf == null || valorDaNf === 0)) {
model.valorDaNf = Number((valorUnit * quantidade).toFixed(2));
} else if (quantidade != null && valorDaNf != null && (valorUnit == null || valorUnit === 0)) {
model.valorUnit = Number((valorDaNf / quantidade).toFixed(2));
}
}
saveControleCreate() {
if (!this.controleCreateModel) return;
this.recalculateControleTotals(this.controleCreateModel);
this.controleCreateSaving = true;
const payload: CreateControleRecebidoRequest = {
ano: this.toNullableNumber(this.controleCreateModel.ano),
item: this.toNullableNumber(this.controleCreateModel.item),
notaFiscal: this.controleCreateModel.notaFiscal,
chip: this.controleCreateModel.chip,
serial: this.controleCreateModel.serial,
conteudoDaNf: this.controleCreateModel.conteudoDaNf,
numeroDaLinha: this.controleCreateModel.numeroDaLinha,
valorUnit: this.toNullableNumber(this.controleCreateModel.valorUnit),
valorDaNf: this.toNullableNumber(this.controleCreateModel.valorDaNf),
dataDaNf: this.dateInputToIso(this.controleCreateDataNf),
dataDoRecebimento: this.dateInputToIso(this.controleCreateRecebimento),
quantidade: this.toNullableNumber(this.controleCreateModel.quantidade),
isResumo: this.controleCreateModel.isResumo ?? false
};
this.service.createControleRecebido(payload).subscribe({
next: () => {
this.controleCreateSaving = false;
this.closeControleCreate();
this.fetchControle();
this.showToast('Recebimento criado com sucesso!', 'success');
},
error: () => {
this.controleCreateSaving = false;
this.showToast('Erro ao criar recebimento.', 'danger');
}
});
}
openControleEdit(row: ControleRecebidoListDto) {
if (!this.isAdmin) return;
this.service.getControleRecebidoById(row.id).subscribe({
next: (data) => {
this.controleEditingId = data.id;
this.controleEditModel = { ...data };
this.controleEditDataNf = this.toDateInput(data.dataDaNf);
this.controleEditRecebimento = this.toDateInput(data.dataDoRecebimento);
this.controleEditOpen = true;
},
error: () => this.showToast('Erro ao abrir edição.', 'danger')
});
}
closeControleEdit() {
this.controleEditOpen = false;
this.controleEditSaving = false;
this.controleEditModel = null;
this.controleEditDataNf = '';
this.controleEditRecebimento = '';
this.controleEditingId = null;
}
saveControleEdit() {
if (!this.controleEditModel || !this.controleEditingId) return;
this.recalculateControleTotals(this.controleEditModel);
this.controleEditSaving = true;
const payload: UpdateControleRecebidoRequest = {
ano: this.toNullableNumber(this.controleEditModel.ano),
item: this.toNullableNumber(this.controleEditModel.item),
notaFiscal: this.controleEditModel.notaFiscal,
chip: this.controleEditModel.chip,
serial: this.controleEditModel.serial,
conteudoDaNf: this.controleEditModel.conteudoDaNf,
numeroDaLinha: this.controleEditModel.numeroDaLinha,
valorUnit: this.toNullableNumber(this.controleEditModel.valorUnit),
valorDaNf: this.toNullableNumber(this.controleEditModel.valorDaNf),
dataDaNf: this.dateInputToIso(this.controleEditDataNf),
dataDoRecebimento: this.dateInputToIso(this.controleEditRecebimento),
quantidade: this.toNullableNumber(this.controleEditModel.quantidade),
isResumo: this.controleEditModel.isResumo ?? false
};
this.service.updateControleRecebido(this.controleEditingId, payload).subscribe({
next: () => {
this.controleEditSaving = false;
this.closeControleEdit();
this.fetchControle();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.controleEditSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
openControleDelete(row: ControleRecebidoListDto) {
if (!this.isAdmin) return;
this.controleDeleteTarget = row;
this.controleDeleteOpen = true;
}
cancelControleDelete() {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
}
async confirmControleDelete() {
if (!this.controleDeleteTarget) return;
if (!(await confirmDeletionWithTyping('este registro de controle recebido'))) return;
const id = this.controleDeleteTarget.id;
this.service.removeControleRecebido(id).subscribe({
next: () => {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
this.fetchControle();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.controleDeleteOpen = false;
this.controleDeleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
closeControleDetail() {
this.controleDetailOpen = false;
this.controleDetailLoading = false;
this.controleDetailData = null;
}
// =====================
// Paginação e Helpers
// =====================
get activePage() { return this.activeTab === 'chips' ? this.chipsPage : this.controlePage; }
get activeTotal() { return this.activeTab === 'chips' ? this.chipsTotal : this.controleTotal; }
get activePageSize() { return this.activeTab === 'chips' ? this.chipsPageSize : this.controlePageSize; }
get activeTotalPages() { return Math.max(1, Math.ceil((this.activeTotal || 0) / (this.activePageSize || 10))); }
get activePageStart() { return this.activeTotal === 0 ? 0 : (this.activePage - 1) * this.activePageSize + 1; }
get activePageEnd() { return this.activeTotal === 0 ? 0 : Math.min(this.activePage * this.activePageSize, this.activeTotal); }
get activeLoading() { return this.activeTab === 'chips' ? this.chipsLoading : this.controleLoading; } // ✅ novo
get activePageNumbers() {
const total = this.activeTotalPages;
const current = this.activePage;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
goToPage(p: number) {
const target = Math.max(1, Math.min(this.activeTotalPages, p));
if (this.activeTab === 'chips') {
this.chipsPage = target;
this.applyChipsPagination();
} else {
this.controlePage = target;
this.applyControlePagination();
}
}
normalizeChip(x: any, idx: number): ChipVirgemListDto {
return {
id: String(x.id || idx),
item: Number(x.item || 0),
numeroDoChip: x.numeroDoChip || x.NumeroDoChip,
observacoes: x.observacoes || x.Observacoes
};
}
normalizeControle(x: any, idx: number): ControleRecebidoListDto {
return {
id: String(x.id || idx),
ano: x.ano,
item: x.item,
notaFiscal: x.notaFiscal,
chip: x.chip,
serial: x.serial,
conteudoDaNf: x.conteudoDaNf,
numeroDaLinha: x.numeroDaLinha,
valorUnit: x.valorUnit,
valorDaNf: x.valorDaNf,
dataDaNf: x.dataDaNf,
dataDoRecebimento: x.dataDoRecebimento,
quantidade: x.quantidade,
isResumo: x.isResumo
};
}
display(val: any) { return val ? String(val) : '-'; }
formatMoney(val: any) {
if (!val) return '-';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val);
}
formatDate(val: any) { if (!val) return '-'; return new Date(val).toLocaleDateString('pt-BR'); }
isResumo(r: any) { return !!r.isResumo; }
getResumoItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => this.isResumo(r)); }
getDetalheItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => !this.isResumo(r)); }
trackById(idx: number, item: any) { return item.id; }
showToast(msg: string, type: 'success' | 'danger') {
this.toastMessage = msg;
this.toastType = type;
this.toastOpen = true;
setTimeout(() => this.toastOpen = false, 3000);
}
}

View File

@ -22,16 +22,19 @@
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge" data-animate>
<i class="bi bi-people-fill"></i> DADOS USUÁRIOS
<i class="bi bi-people-fill"></i> DADOS PF/PJ
</div>
<div class="header-title" data-animate>
<h5 class="title mb-0">GESTÃO DE USUÁRIOS</h5>
<small class="subtitle">Base de dados agrupada por cliente</small>
<h5 class="title mb-0">GESTÃO DE USUÁRIOS PF/PJ</h5>
<small class="subtitle">Base de dados separada por pessoa física e jurídica</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
<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()">
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
</button>
</div>
</div>
@ -51,10 +54,10 @@
</span>
</div>
<div class="kpi">
<span class="lbl text-success">Com CPF</span>
<span class="lbl text-success">{{ tipoFilter === 'PJ' ? 'Com CNPJ' : 'Com CPF' }}</span>
<span class="val text-success">
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loading">{{ kpiComCpf || 0 }}</span>
<span *ngIf="!loading">{{ tipoFilter === 'PJ' ? (kpiComCnpj || 0) : (kpiComCpf || 0) }}</span>
</span>
</div>
<div class="kpi">
@ -67,21 +70,25 @@
</div>
<div class="controls mt-3 mb-2" data-animate>
<div class="filter-tabs tipo-filter-tabs">
<button type="button" class="filter-tab" [class.active]="tipoFilter === 'PF'" (click)="setTipoFilter('PF')">
Pessoa Física
</button>
<button type="button" class="filter-tab" [class.active]="tipoFilter === 'PJ'" (click)="setTipoFilter('PJ')">
Pessoa Jurídica
</button>
</div>
<div class="input-group input-group-sm search-group">
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span>
<input class="form-control" placeholder="Pesquisar cliente, linha, cpf..." [(ngModel)]="search" (ngModelChange)="onSearch()" />
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="search" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearFilters()" *ngIf="search"><i class="bi bi-x-lg"></i></button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -103,7 +110,8 @@
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
<div class="group-badges">
<span class="badge-pill total">{{ g.totalRegistros }} Registros</span>
<span class="badge-pill ok" *ngIf="g.comCpf > 0">{{ g.comCpf }} CPF</span>
<span class="badge-pill ok" *ngIf="tipoFilter === 'PF' && g.comCpf > 0">{{ g.comCpf }} CPF</span>
<span class="badge-pill ok" *ngIf="tipoFilter === 'PJ' && g.comCnpj > 0">{{ g.comCnpj }} CNPJ</span>
<span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span>
</div>
</div>
@ -126,10 +134,10 @@
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CPF</th>
<th>{{ tipoFilter === 'PJ' ? 'CNPJ' : 'CPF' }}</th>
<th>E-MAIL</th>
<th>CELULAR</th>
<th style="min-width: 80px;">AÇÕES</th>
<th class="actions-col">AÇÕES</th>
</tr>
</thead>
<tbody>
@ -139,12 +147,14 @@
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha }}</td>
<td class="small font-monospace">{{ r.cpf || '-' }}</td>
<td class="small font-monospace">{{ tipoFilter === 'PJ' ? (r.cnpj || '-') : (r.cpf || '-') }}</td>
<td class="text-muted small td-clip" [title]="r.email">{{ r.email || '-' }}</td>
<td class="text-muted small">{{ r.celular || '-' }}</td>
<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>
</div>
</td>
</tr>
@ -171,9 +181,9 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="detailsOpen" (click)="closeDetails()"></div>
<div class="modal-custom" *ngIf="detailsOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-backdrop-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="modal-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()">
<div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
@ -188,10 +198,11 @@
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div>
<div class="form-field"><label>TIPO</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'PESSOA JURÍDICA' : 'PESSOA FÍSICA' }}</div></div>
<div class="form-field span-2"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'RAZÃO SOCIAL' : 'NOME' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.razaoSocial || selectedRow?.cliente || '-') : (selectedRow?.nome || selectedRow?.cliente || '-') }}</div></div>
<div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div>
<div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div>
<div class="form-field"><label>CPF</label><div>{{ selectedRow?.cpf || '-' }}</div></div>
<div class="form-field"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'CNPJ' : 'CPF' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.cnpj || '-') : (selectedRow?.cpf || '-') }}</div></div>
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div>
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
@ -206,4 +217,223 @@
</div>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div *ngIf="createOpen" class="modal-card modal-xl-custom create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Novo Usuário
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="createModel">
<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>
<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>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-vcard me-2"></i> Dados do Usuário</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="createModel.tipoPessoa"
(ngModelChange)="onCreateTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'">
<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-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" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>CPF</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cpf" /></div>
<div class="form-field field-cpf-cnpj" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'"><label>CNPJ</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cnpj" /></div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="createModel.rg" /></div>
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" [(ngModel)]="createModel.email" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="createModel.endereco" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="createModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="createModel.telefoneFixo" /></div>
<div class="form-field span-2" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>Data de Nascimento</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createDateNascimento" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Usuário
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid">
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field field-tipo"><label>Tipo</label>
<app-select
class="form-select"
size="sm"
[options]="tipoPessoaOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="editModel.tipoPessoa"
(ngModelChange)="onEditTipoChange()">
</app-select>
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Nome</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.nome" />
</div>
<div class="form-field span-2" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<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-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" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>CPF</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cpf" />
</div>
<div class="form-field field-cpf-cnpj" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
<label>CNPJ</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cnpj" />
</div>
<div class="form-field field-rg"><label>RG</label><input class="form-control form-control-sm" [(ngModel)]="editModel.rg" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-envelope-paper me-2"></i> Contato</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid user-modal-grid contact-modal-grid">
<div class="form-field span-2"><label>E-mail</label><input class="form-control form-control-sm" type="email" [(ngModel)]="editModel.email" /></div>
<div class="form-field field-celular"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="editModel.celular" /></div>
<div class="form-field field-telefone"><label>Telefone Fixo</label><input class="form-control form-control-sm" [(ngModel)]="editModel.telefoneFixo" /></div>
<div class="form-field span-2"><label>Endereço</label><input class="form-control form-control-sm" [(ngModel)]="editModel.endereco" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Complemento</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" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
<label>Data Nascimento</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="editDateNascimento" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Usuário
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>

View File

@ -112,7 +112,22 @@
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
.header-actions { justify-self: end; }
.header-actions {
justify-self: end;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
white-space: nowrap;
}
}
/* Buttons */
.btn-brand {
@ -177,6 +192,74 @@
/* Controls */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.filter-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(17, 18, 20, 0.04);
}
.filter-tab {
border: 1px solid transparent;
background: transparent;
padding: 8px 14px;
border-radius: 8px;
font-size: 0.84rem;
font-weight: 800;
color: rgba(17, 18, 20, 0.62);
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
min-height: 34px;
&:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.72);
border-color: rgba(17, 18, 20, 0.08);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
}
&.active {
background: #fff;
color: var(--brand);
border-color: rgba(227, 61, 207, 0.16);
box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15);
}
}
.tipo-filter-tabs {
.filter-tab {
min-width: 122px;
}
}
@media (max-width: 700px) {
.tipo-filter-tabs {
width: 100%;
justify-content: stretch;
}
.tipo-filter-tabs .filter-tab {
flex: 1 1 0;
min-width: 0;
padding: 8px 10px;
font-size: 0.8rem;
}
}
.search-group {
max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease;
&:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); }
@ -241,12 +324,24 @@
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
.empty-state { background: rgba(255,255,255,0.4); }
.actions-col { min-width: 152px; }
.action-group {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.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: #dc3545; background: rgba(220, 53, 69, 0.12); }
}
/* FOOTER */
@ -256,17 +351,338 @@
/* MODALS */
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: clamp(12px, 2.2vw, 20px); }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
.modal-xl-custom { width: min(1050px, 95vw); max-height: 86vh; }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } }
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } }
.modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
.modal-card.create-modal .edit-sections { gap: 14px; }
.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
.modal-card.create-modal .form-control,
.modal-card.create-modal .form-select { min-height: 40px; }
.modal-card.create-modal .modal-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 14px 20px !important;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
}
.modal-card.create-modal .modal-footer .btn {
border-radius: 12px;
font-weight: 900;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 120px;
}
.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
.modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
.modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
.modal-card.create-modal summary.box-header {
padding: 10px 12px;
span {
font-size: 0.72rem;
line-height: 1.2;
gap: 7px;
}
i:not(.transition-icon) {
width: 20px;
height: 20px;
border-radius: 6px;
font-size: 0.8rem;
}
}
.form-field.field-line {
order: initial;
grid-column: span 2;
}
.form-field.field-line .form-control {
min-height: 42px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.form-field.field-item {
align-items: flex-start;
}
.form-field.field-item .form-control {
width: 100%;
max-width: none;
min-height: 38px;
text-align: left;
font-size: 0.82rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.user-modal-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 10px;
}
.user-modal-grid .span-2,
.user-modal-grid .field-tipo,
.user-modal-grid .field-line,
.user-modal-grid .field-rg {
grid-column: span 2 !important;
}
.user-modal-grid .field-item,
.user-modal-grid .field-cpf-cnpj,
.user-modal-grid .field-celular,
.user-modal-grid .field-telefone {
grid-column: span 1 !important;
min-width: 0;
}
.user-modal-grid .field-item .field-hint {
display: none;
}
.user-modal-grid .field-tipo .form-control {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
min-height: 42px;
border-radius: 12px;
border-color: rgba(3, 15, 170, 0.18);
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,248,251,0.98));
box-shadow: 0 2px 8px rgba(3, 15, 170, 0.06);
padding-right: 34px;
font-weight: 800;
color: var(--blue);
background-image:
linear-gradient(45deg, transparent 50%, rgba(17,18,20,0.55) 50%),
linear-gradient(135deg, rgba(17,18,20,0.55) 50%, transparent 50%);
background-position:
calc(100% - 16px) calc(50% - 2px),
calc(100% - 11px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.user-modal-grid .field-tipo .form-control:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.14);
}
.user-modal-grid .field-cpf-cnpj .form-control,
.user-modal-grid .field-item .form-control {
min-height: 40px;
font-size: 0.84rem;
font-variant-numeric: tabular-nums;
}
.user-modal-grid .field-rg .form-control {
min-height: 40px;
font-size: 0.88rem;
max-width: 100%;
}
.contact-modal-grid .field-celular .form-control,
.contact-modal-grid .field-telefone .form-control {
width: 100%;
min-width: 0;
min-height: 40px;
}
}
/* FORM & DETAILS */
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
div.box-header {
padding: 10px 16px;
font-size: 0.8rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--brand);
border-bottom: 1px solid rgba(0,0,0,0.04);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
display: flex;
align-items: center;
}
div.box-body { padding: 16px; }
.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 { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); &:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; } }
.edit-sections { display: grid; gap: 12px; }
.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
summary.box-header {
cursor: pointer;
user-select: none;
list-style: none;
border-radius: 14px 14px 0 0;
transition: background 0.2s ease, color 0.2s ease;
i:not(.transition-icon) {
color: var(--brand);
margin-right: 6px;
}
&::-webkit-details-marker { display: none; }
}
.modal-card.create-modal summary.box-header {
padding: 11px 14px;
border-bottom-color: rgba(227, 61, 207, 0.08);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.10), rgba(3, 15, 170, 0.06));
span {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
font-size: 0.76rem;
line-height: 1.2;
color: rgba(17, 18, 20, 0.84);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 900;
white-space: normal;
overflow-wrap: anywhere;
}
i:not(.transition-icon) {
width: 22px;
height: 22px;
margin-right: 0;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(227, 61, 207, 0.12);
color: var(--brand);
flex-shrink: 0;
}
}
.transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17,18,20,0.64);
}
&.span-2 { grid-column: span 2; }
}
.field-hint {
display: block;
font-size: 0.66rem;
line-height: 1.2;
color: rgba(17, 18, 20, 0.52);
font-weight: 700;
}
.form-field.field-auto .form-control {
background: rgba(245, 245, 247, 0.9);
border-color: rgba(17, 18, 20, 0.1);
color: rgba(17, 18, 20, 0.72);
}
.form-field.field-auto .form-control[readonly] {
cursor: default;
}
.details-dashboard .form-field > div {
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
background: rgba(245, 245, 247, 0.65);
min-height: 42px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 700;
color: var(--text);
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17,18,20,0.15);
background: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.38); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227,61,207,0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { DadosUsuarios } from './dados-usuarios';
describe('DadosUsuarios', () => {
@ -8,6 +11,11 @@ describe('DadosUsuarios', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DadosUsuarios], // standalone component
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(DadosUsuarios);

View File

@ -1,25 +1,43 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import {
DadosUsuariosService,
UserDataClientGroup,
UserDataRow,
UserDataGroupResponse,
PagedResult
PagedResult,
UpdateUserDataRequest,
CreateUserDataRequest
} from '../../services/dados-usuarios.service';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
type ViewMode = 'lines' | 'groups';
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
usuario: string | null;
label?: string;
}
interface SimpleOption {
label: string;
value: string;
}
@Component({
selector: 'app-dados-usuarios',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './dados-usuarios.html',
styleUrls: ['./dados-usuarios.scss'],
providers: [DadosUsuariosService]
styleUrls: ['./dados-usuarios.scss']
})
export class DadosUsuarios implements OnInit {
@ -30,10 +48,12 @@ export class DadosUsuarios implements OnInit {
// Filtros
search = '';
tipoFilter: 'PF' | 'PJ' = 'PF';
// Paginação
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// Ordenação
@ -51,6 +71,7 @@ export class DadosUsuarios implements OnInit {
kpiTotalRegistros = 0;
kpiClientesUnicos = 0;
kpiComCpf = 0;
kpiComCnpj = 0;
kpiComEmail = 0;
// ACORDEÃO
@ -61,15 +82,42 @@ export class DadosUsuarios implements OnInit {
// Modal / Toast
detailsOpen = false;
selectedRow: UserDataRow | null = null;
editOpen = false;
editSaving = false;
editModel: UserDataRow | null = null;
editDateNascimento = '';
editingId: string | null = null;
deleteOpen = false;
deleteTarget: UserDataRow | null = null;
createOpen = false;
createSaving = false;
createModel: any = null;
createDateNascimento = '';
clientsFromGeral: string[] = [];
lineOptionsCreate: LineOptionDto[] = [];
readonly tipoPessoaOptions: SimpleOption[] = [
{ label: 'Pessoa Física', value: 'PF' },
{ label: 'Pessoa Jurídica', value: 'PJ' },
];
createClientsLoading = false;
createLinesLoading = false;
isAdmin = false;
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: any = null;
private searchTimer: any = null;
constructor(private service: DadosUsuariosService) {}
constructor(
private service: DadosUsuariosService,
private authService: AuthService,
private linesService: LinesService
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('sysadmin');
this.fetch(1);
}
@ -127,6 +175,7 @@ export class DadosUsuarios implements OnInit {
private fetchGroups() {
this.service.getGroups({
search: this.search?.trim(),
tipo: this.tipoFilter,
page: this.page,
pageSize: this.pageSize,
sortBy: this.sortBy,
@ -139,6 +188,7 @@ export class DadosUsuarios implements OnInit {
this.kpiTotalRegistros = res.kpis.totalRegistros;
this.kpiClientesUnicos = res.kpis.clientesUnicos;
this.kpiComCpf = res.kpis.comCpf;
this.kpiComCnpj = res.kpis.comCnpj;
this.kpiComEmail = res.kpis.comEmail;
this.loading = false;
@ -167,6 +217,7 @@ export class DadosUsuarios implements OnInit {
this.service.getRows({
client: g.cliente,
tipo: this.tipoFilter,
page: 1,
pageSize: 200,
sortBy: 'item',
@ -192,6 +243,15 @@ export class DadosUsuarios implements OnInit {
}, 400);
}
setTipoFilter(tipo: 'PF' | 'PJ') {
if (this.tipoFilter === tipo) return;
this.tipoFilter = tipo;
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.fetch();
}
clearFilters() { this.search = ''; this.fetch(1); }
onPageSizeChange() {
@ -207,7 +267,13 @@ export class DadosUsuarios implements OnInit {
openDetails(row: UserDataRow) {
this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => {
this.selectedRow = fullData;
const tipo = this.normalizeTipo(fullData);
this.selectedRow = {
...fullData,
tipoPessoa: tipo,
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
};
this.detailsOpen = true;
},
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
@ -216,9 +282,316 @@ export class DadosUsuarios implements OnInit {
closeDetails() { this.detailsOpen = false; }
openEdit(row: UserDataRow) {
if (!this.isAdmin) return;
this.service.getById(row.id).subscribe({
next: (fullData: UserDataRow) => {
this.editingId = fullData.id;
const tipo = this.normalizeTipo(fullData);
this.editModel = {
...fullData,
tipoPessoa: tipo,
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
};
this.editDateNascimento = this.toDateInput(fullData.dataNascimento);
this.editOpen = true;
},
error: () => this.showToast('Erro ao abrir edição', 'danger')
});
}
closeEdit() {
this.editOpen = false;
this.editSaving = false;
this.editModel = null;
this.editDateNascimento = '';
this.editingId = null;
}
onEditTipoChange() {
if (!this.editModel) return;
const tipo = (this.editModel.tipoPessoa ?? 'PF').toString().toUpperCase();
this.editModel.tipoPessoa = tipo;
if (tipo === 'PJ') {
this.editModel.cpf = '';
if (!this.editModel.razaoSocial) this.editModel.razaoSocial = this.editModel.cliente;
} else {
this.editModel.cnpj = '';
if (!this.editModel.nome) this.editModel.nome = this.editModel.cliente;
}
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const tipo = (this.editModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
const cliente = tipo === 'PJ'
? (this.editModel.razaoSocial || this.editModel.cliente)
: (this.editModel.nome || this.editModel.cliente);
const payload: UpdateUserDataRequest = {
item: this.toNullableNumber(this.editModel.item),
linha: this.editModel.linha,
cliente,
tipoPessoa: tipo,
nome: this.editModel.nome,
razaoSocial: this.editModel.razaoSocial,
cnpj: this.editModel.cnpj,
cpf: this.editModel.cpf,
rg: this.editModel.rg,
email: this.editModel.email,
endereco: this.editModel.endereco,
celular: this.editModel.celular,
telefoneFixo: this.editModel.telefoneFixo,
dataNascimento: this.dateInputToIso(this.editDateNascimento)
};
this.service.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.fetch();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.editSaving = false;
this.showToast('Erro ao salvar alterações.', 'danger');
}
});
}
// ==========================
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
}
closeCreate() {
this.createOpen = false;
this.createSaving = false;
this.createModel = null;
}
private resetCreateModel() {
this.createModel = {
selectedClient: '',
mobileLineId: '',
item: '',
linha: '',
cliente: '',
tipoPessoa: this.tipoFilter,
nome: '',
razaoSocial: '',
cnpj: '',
cpf: '',
rg: '',
email: '',
endereco: '',
celular: '',
telefoneFixo: ''
};
this.createDateNascimento = '';
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() {
const tipo = (this.createModel?.tipoPessoa ?? 'PF').toString().toUpperCase();
this.createModel.tipoPessoa = tipo;
if (tipo === 'PJ') {
this.createModel.cpf = '';
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
} else {
this.createModel.cnpj = '';
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
}
}
private loadLinesForClient(cliente: string) {
const c = (cliente ?? '').trim();
if (!c) return;
this.createLinesLoading = true;
this.linesService.getLinesByClient(c).subscribe({
next: (items: any[]) => {
const mapped: LineOptionDto[] = (items ?? [])
.filter(x => !!String(x?.id ?? '').trim())
.map(x => ({
id: String(x.id),
item: Number(x.item ?? 0),
linha: x.linha ?? null,
usuario: x.usuario ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());
this.lineOptionsCreate = mapped;
this.createLinesLoading = false;
},
error: () => {
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
}
});
}
onCreateLineChange() {
const id = String(this.createModel?.mobileLineId ?? '').trim();
if (!id) return;
this.linesService.getById(id).subscribe({
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
});
}
private applyLineDetailToCreate(d: MobileLineDetail) {
this.createModel.linha = d.linha ?? '';
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
if (!String(this.createModel.item ?? '').trim() && d.item) {
this.createModel.item = String(d.item);
}
if ((this.createModel.tipoPessoa ?? '').toUpperCase() === 'PJ') {
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
} else {
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
}
}
saveCreate() {
if (!this.createModel) return;
this.createSaving = true;
const tipo = (this.createModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
const cliente = tipo === 'PJ'
? (this.createModel.razaoSocial || this.createModel.cliente)
: (this.createModel.nome || this.createModel.cliente);
const payload: CreateUserDataRequest = {
item: this.toNullableNumber(this.createModel.item),
linha: this.createModel.linha,
cliente,
tipoPessoa: tipo,
nome: this.createModel.nome,
razaoSocial: this.createModel.razaoSocial,
cnpj: this.createModel.cnpj,
cpf: this.createModel.cpf,
rg: this.createModel.rg,
email: this.createModel.email,
endereco: this.createModel.endereco,
celular: this.createModel.celular,
telefoneFixo: this.createModel.telefoneFixo,
dataNascimento: this.dateInputToIso(this.createDateNascimento)
};
this.service.create(payload).subscribe({
next: () => {
this.createSaving = false;
this.closeCreate();
this.fetch();
this.showToast('Usuário criado com sucesso!', 'success');
},
error: () => {
this.createSaving = false;
this.showToast('Erro ao criar usuário.', 'danger');
}
});
}
openDelete(row: UserDataRow) {
if (!this.isAdmin) return;
this.deleteTarget = row;
this.deleteOpen = true;
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
}
async confirmDelete() {
if (!this.deleteTarget) return;
if (!(await confirmDeletionWithTyping('este registro de dados do usuário'))) return;
const id = this.deleteTarget.id;
this.service.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.fetch();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
trackById(_: number, row: UserDataRow) { return row.id; }
trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; }
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
private normalizeTipo(row: UserDataRow | null | undefined): 'PF' | 'PJ' {
const t = (row?.tipoPessoa ?? '').toString().trim().toUpperCase();
if (t === 'PJ') return 'PJ';
if (t === 'PF') return 'PF';
if (row?.cnpj) return 'PJ';
return 'PF';
}
showToast(msg: string, type: 'success' | 'danger') {
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
if(this.toastTimer) clearTimeout(this.toastTimer);
@ -226,4 +599,4 @@ export class DadosUsuarios implements OnInit {
}
hideToast() { this.toastOpen = false; }
}
}

View File

@ -0,0 +1,370 @@
<section class="dashboard-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-dashboard">
<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
</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>
</div>
<div class="head-actions">
<div class="status-indicator" *ngIf="loading">
<span class="spinner-border spinner-border-sm text-brand"></span>
<span>Atualizando dados...</span>
</div>
<div class="status-indicator error" *ngIf="!loading && errorMsg">
<i class="bi bi-exclamation-triangle-fill"></i> {{ errorMsg }}
</div>
<div class="last-update" *ngIf="!loading && !errorMsg">
<i class="bi bi-check2-circle"></i> Dados atualizados
</div>
</div>
</div>
<div class="hero-grid fade-in-up" [style.animation-delay]="'100ms'">
<div class="hero-card" *ngFor="let k of kpis; trackBy: trackByKpiKey">
<div class="hero-icon">
<i [class]="k.icon"></i>
</div>
<div class="hero-data">
<span class="hero-label">{{ k.title }}</span>
<span class="hero-value">{{ k.value }}</span>
<span class="hero-hint" *ngIf="k.hint">{{ k.hint }}</span>
</div>
</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>
</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 da Base</h3>
<p>Distribuição atual das linhas</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 class="status-item">
<span class="dot d-reserve"></span>
<span class="lbl">Reservas</span>
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
</div>
<div class="status-item">
<span class="dot d-blocked"></span>
<span class="lbl">Bloq. (Perda/Roubo)</span>
<span class="val">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</span>
</div>
<div class="status-item">
<span class="dot d-blocked-soft"></span>
<span class="lbl">Bloq. (120 dias)</span>
<span class="val">{{ statusResumo.bloq120 | number:'1.0-0' }}</span>
</div>
<div class="status-item total-row">
<span class="lbl">Total Geral</span>
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
</div>
</div>
</div>
</div>
<div class="card-modern card-adicionais">
<div class="card-header-clean">
<div class="header-icon blue"><i class="bi bi-diagram-3-fill"></i></div>
<div class="header-text">
<h3>Serviços Adicionais</h3>
<p>Comparativo de linhas com e sem adicionais (Geral)</p>
</div>
</div>
<div class="card-body-adicionais">
<div class="chart-wrapper-pie-sm">
<canvas #chartAdicionaisComparativo></canvas>
</div>
<div class="compare-list">
<div class="compare-item">
<span class="dot d-com-add"></span>
<span class="lbl">Com adicionais</span>
<span class="val">{{ adicionaisComparativo.com | number:'1.0-0' }}</span>
<span class="pct">{{ adicionaisComparativo.pctCom }}</span>
</div>
<div class="compare-item">
<span class="dot d-sem-add"></span>
<span class="lbl">Sem adicionais</span>
<span class="val">{{ adicionaisComparativo.sem | number:'1.0-0' }}</span>
<span class="pct">{{ adicionaisComparativo.pctSem }}</span>
</div>
<div class="compare-item total-row">
<span class="lbl">Total analisado</span>
<span class="val">{{ adicionaisComparativo.total | number:'1.0-0' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'280ms'">
<div class="grid-halves">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon warning"><i class="bi bi-shield-exclamation"></i></div>
<div class="header-text">
<h3>Vigência (Buckets)</h3>
<p>Status de vencimento atual</p>
</div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartVigenciaSupervisao></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon blue"><i class="bi bi-globe2"></i></div>
<div class="header-text">
<h3>Vivo Travel</h3>
<p>Linhas com e sem serviço ativo</p>
</div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartTravelMundo></canvas>
</div>
</div>
</div>
<div class="grid-triples mt-3">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Linhas por Franquia</h3>
<p>Distribuição da base por faixa de franquia</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartLinhasPorFranquia></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Adicionais Pagos (Serviços)</h3>
<p>Quantidade de linhas por serviço adicional ativo</p>
</div>
</div>
<div class="chart-wrapper-bar compact">
<canvas #chartAdicionaisPagos></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Tipo de Chip</h3>
<p>Quantidade de linhas e-SIM e SIMCARD</p>
</div>
</div>
<div class="chart-wrapper-pie">
<canvas #chartTipoChip></canvas>
</div>
</div>
</div>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'320ms'">
<h2>Página Resumo</h2>
<p>Indicadores do Resumo focados em quantidade e distribuição de linhas.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'360ms'">
<div class="card-modern full-width">
<div class="toolbar-header">
<div class="title-group">
<i class="bi bi-bar-chart-line text-brand"></i>
<div>
<h3>Resumo Operacional de Linhas</h3>
<p>Dados consolidados da página Resumo sem foco financeiro</p>
</div>
</div>
<div class="toolbar-controls">
<div class="control-group">
<label>Visualizar Top</label>
<select
[value]="resumoTopN"
(change)="resumoTopN = +($any($event.target).value); onResumoTopNChange()"
class="form-select-sm">
<option *ngFor="let size of resumoTopOptions" [value]="size">{{ size }}</option>
</select>
</div>
<div class="divider-v"></div>
<a class="btn-link" [routerLink]="['/resumo']" [queryParams]="{ tab: 'totais' }">Ver Página Resumo <i class="bi bi-arrow-right"></i></a>
</div>
</div>
<div class="card-body-grid">
<div class="loading-overlay" *ngIf="resumoLoading">
<div class="spinner-border text-brand" role="status"></div>
</div>
<div class="error-state" *ngIf="!resumoLoading && resumoError">
<i class="bi bi-exclamation-circle"></i>
<span>{{ resumoError }}</span>
</div>
<div class="analytics-grid" *ngIf="!resumoLoading && !resumoError">
<div class="mini-chart-card">
<h6>Top Clientes (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopClientes></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Top Planos (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoTopPlanos></canvas></div>
</div>
<div class="mini-chart-card">
<h6>PF vs PJ (Qtd. Linhas)</h6>
<div class="chart-area"><canvas #chartResumoPfPjLinhas></canvas></div>
</div>
<div class="mini-chart-card">
<h6>Reserva por DDD</h6>
<div class="chart-area"><canvas #chartResumoReservaDdd></canvas></div>
</div>
<div class="mini-chart-card mini-metric-card">
<h6>DIFERENÇA PJ X PF</h6>
<div class="metric-stack">
<div class="metric-line">
<span>PF (Linhas)</span>
<strong>{{ formatInt(resumoDiferencaPjPf.pfLinhas) }}</strong>
</div>
<div class="metric-line">
<span>PJ (Linhas)</span>
<strong>{{ formatInt(resumoDiferencaPjPf.pjLinhas) }}</strong>
</div>
<div class="metric-line">
<span>Total Linhas</span>
<strong>{{ formatInt(resumoDiferencaPjPf.totalLinhas) }}</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="context-title fade-in-up" [style.animation-delay]="'420ms'">
<h2>Histórico</h2>
<p>Séries mensais para acompanhamento contínuo de movimentações e vigência.</p>
</div>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'460ms'">
<div class="history-grid">
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>MUREG (12 Meses)</h3>
<p>Histórico mensal de mudanças de plano/aparelho</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartMureg12></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-text">
<h3>Troca de Número (12 Meses)</h3>
<p>Histórico mensal de trocas realizadas</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartTroca12></canvas>
</div>
</div>
<div class="card-modern">
<div class="card-header-clean">
<div class="header-icon purple"><i class="bi bi-calendar2-check"></i></div>
<div class="header-text">
<h3>Vigência (Próx. 12 Meses)</h3>
<p>Contratos a encerrar por mês</p>
</div>
</div>
<div class="chart-wrapper-bar compact-half">
<canvas #chartVigenciaMesAno></canvas>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #clienteDashboard>
<div class="dashboard-section fade-in-up" [style.animation-delay]="'180ms'">
<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 da Base</h3>
<p>Distribuição atual das linhas</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 class="status-item">
<span class="dot d-blocked"></span>
<span class="lbl">Bloqueadas</span>
<span class="val">{{ statusResumo.bloqueadas | number:'1.0-0' }}</span>
</div>
<div class="status-item">
<span class="dot d-reserve"></span>
<span class="lbl">Reserva</span>
<span class="val">{{ statusResumo.reservas | number:'1.0-0' }}</span>
</div>
<div class="status-item total-row">
<span class="lbl">Total</span>
<span class="val">{{ statusResumo.total | number:'1.0-0' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-template>
</div>
</section>

View File

@ -0,0 +1,622 @@
/* ========================================================== */
/* VARIÁVEIS & SETUP (Consistente com Geral/Resumo) */
/* ========================================================== */
:host {
--brand: #E33DCF;
--brand-soft: rgba(227, 61, 207, 0.08);
--brand-hover: #c92bb6;
--blue: #030FAA;
--blue-soft: rgba(3, 15, 170, 0.08);
--text-main: #111827;
--text-muted: rgba(17, 18, 20, 0.65);
--bg-page: #f8fafc;
--card-bg: rgba(255, 255, 255, 0.9);
--glass-border: 1px solid rgba(255, 255, 255, 0.6);
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
--shadow-card: 0 10px 30px -5px rgba(0, 0, 0, 0.06);
--shadow-hover: 0 20px 40px -5px rgba(0, 0, 0, 0.1);
--radius-xl: 20px;
--radius-lg: 16px;
--radius-md: 12px;
/* Cores de Gráfico (Palette do Sistema) */
--chart-pink: #E33DCF;
--chart-blue: #030FAA;
--chart-purple: #6A55FF;
--chart-pink-soft: #F3B0E8;
--chart-dark: #B832A8;
display: block;
font-family: 'Inter', sans-serif;
color: var(--text-main);
}
/* Remove footer se necessário (herdado do antigo css) */
:host ::ng-deep footer,
:host ::ng-deep .footer,
:host ::ng-deep .portal-footer,
:host ::ng-deep .app-footer {
display: none !important;
}
/* ========================================================== */
/* LAYOUT BASE */
/* ========================================================== */
.dashboard-page {
min-height: 100vh;
padding-bottom: 60px;
background:
radial-gradient(circle at 15% 10%, rgba(227, 61, 207, 0.08) 0%, transparent 40%),
radial-gradient(circle at 85% 30%, rgba(3, 15, 170, 0.06) 0%, transparent 40%),
var(--bg-page);
position: relative;
overflow-x: hidden;
}
.page-blob {
position: fixed; pointer-events: none; border-radius: 999px;
filter: blur(40px); opacity: 0.6; z-index: 0;
&.blob-1 { width: 300px; height: 300px; top: -100px; left: -50px; background: rgba(227,61,207,0.3); }
&.blob-2 { width: 400px; height: 400px; top: 20%; right: -100px; background: rgba(3,15,170,0.2); }
&.blob-3 { width: 350px; height: 350px; bottom: 0; left: 20%; background: rgba(106,85,255,0.2); }
}
.container-dashboard {
width: 100%;
max-width: 1380px; /* Largura executiva */
margin: 0 auto;
padding: 24px 20px;
position: relative;
z-index: 1;
}
/* Animações */
.fade-in-up {
opacity: 0;
animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ========================================================== */
/* HEADER */
/* ========================================================== */
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
@media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; }
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 99px;
background: #fff;
border: 1px solid rgba(0,0,0,0.08);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 12px;
box-shadow: var(--shadow-sm);
i { font-size: 14px; }
}
.page-title {
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
margin: 0 0 4px;
color: var(--text-main);
line-height: 1.1;
}
.page-subtitle {
font-size: 15px;
color: var(--text-muted);
margin: 0;
}
.head-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
&.error { color: #dc2626; }
}
.last-update {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
display: flex; align-items: center; gap: 6px;
i { color: #10b981; }
}
/* ========================================================== */
/* HERO KPIS */
/* ========================================================== */
.hero-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.hero-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
&:hover {
transform: translateY(-3px);
background: #fff;
box-shadow: var(--shadow-card);
border-color: rgba(227, 61, 207, 0.2);
}
}
.hero-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--brand), #9d2ec5);
color: #fff;
display: grid;
place-items: center;
font-size: 18px;
box-shadow: 0 4px 10px rgba(227, 61, 207, 0.3);
}
.hero-data {
display: flex; flex-direction: column;
}
.hero-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.hero-value {
font-size: 24px;
font-weight: 800;
color: var(--text-main);
margin-top: 2px;
}
.hero-hint {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
opacity: 0.8;
margin-top: 4px;
}
/* ========================================================== */
/* CARDS MODERNOS (Container Genérico) */
/* ========================================================== */
.card-modern {
background: #fff;
border-radius: var(--radius-xl);
border: 1px solid rgba(0,0,0,0.04);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
&:hover {
box-shadow: var(--shadow-hover);
}
}
.card-header-clean {
padding: 20px 24px;
border-bottom: 1px solid rgba(0,0,0,0.04);
display: flex;
align-items: center;
gap: 16px;
background: rgba(250, 250, 252, 0.5);
.header-icon {
width: 36px; height: 36px;
border-radius: 10px;
display: grid; place-items: center;
font-size: 16px;
&.brand { background: var(--brand-soft); color: var(--brand); }
&.blue { background: var(--blue-soft); color: var(--blue); }
&.purple { background: rgba(106, 85, 255, 0.1); color: var(--chart-purple); }
&.warning { background: rgba(245, 158, 11, 0.1); color: #d97706; }
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--text-main);
}
p {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
}
/* ========================================================== */
/* SEÇÃO 1: STATUS SPLIT */
/* ========================================================== */
.dashboard-section {
margin-bottom: 24px;
}
.context-title {
margin: 4px 0 14px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 800;
color: var(--text-main);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
}
.section-top-row {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 18px;
@media(max-width: 1120px) { grid-template-columns: 1fr; }
}
.section-bottom-row {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
@media(max-width: 900px) { grid-template-columns: 1fr; }
}
.card-body-split {
padding: 14px 16px 12px;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
gap: 12px;
@media(max-width: 700px) { grid-template-columns: 1fr; }
}
.chart-wrapper-pie {
position: relative;
height: 184px;
width: 100%;
}
.status-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
background: #f8fafc;
border-radius: 10px;
font-size: 12px;
.dot { width: 8px; height: 8px; border-radius: 50%; margin-right: 10px; }
.d-active { background: var(--chart-blue); box-shadow: 0 0 0 2px rgba(3, 15, 170, 0.2); }
.d-reserve { background: var(--chart-pink-soft); }
.d-blocked { background: var(--chart-dark); }
.d-blocked-soft { background: var(--chart-purple); }
.lbl { font-weight: 600; color: var(--text-muted); margin-right: auto; }
.val { font-weight: 800; color: var(--text-main); font-size: 13px; }
&.total-row {
background: var(--brand-soft);
border: 1px solid rgba(227, 61, 207, 0.1);
margin-top: 4px;
.lbl { color: var(--brand); text-transform: uppercase; font-size: 11px; font-weight: 800; }
.val { color: var(--brand); font-size: 16px; }
}
}
.chart-wrapper-bar {
padding: 16px 20px 20px;
height: 220px;
position: relative;
&.compact { height: 180px; }
&.compact-half { height: 200px; }
}
.card-adicionais .card-body-adicionais {
padding: 14px 16px 12px;
display: grid;
grid-template-columns: 0.95fr 1.05fr;
gap: 12px;
align-items: center;
@media(max-width: 700px) { grid-template-columns: 1fr; }
}
.chart-wrapper-pie-sm {
position: relative;
height: 186px;
width: 100%;
}
.compare-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.compare-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.05);
font-size: 12px;
.dot { width: 8px; height: 8px; border-radius: 50%; }
.d-com-add { background: var(--chart-purple); box-shadow: 0 0 0 2px rgba(106, 85, 255, 0.2); }
.d-sem-add { background: var(--brand); box-shadow: 0 0 0 2px rgba(227, 61, 207, 0.18); }
.lbl { color: var(--text-muted); font-weight: 700; }
.val { color: var(--text-main); font-weight: 800; }
.pct {
color: var(--blue);
font-weight: 800;
background: rgba(3, 15, 170, 0.1);
border-radius: 999px;
padding: 2px 8px;
}
&.total-row {
grid-template-columns: 1fr auto;
.lbl { color: var(--brand); text-transform: uppercase; font-size: 11px; letter-spacing: 0.03em; }
.val { color: var(--brand); font-size: 14px; }
}
}
/* ========================================================== */
/* SEÇÃO 2: ANALYTICS INTEGRADO (TOOLBAR) */
/* ========================================================== */
.toolbar-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(0,0,0,0.06);
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.title-group {
display: flex;
align-items: flex-start;
gap: 12px;
i { font-size: 20px; margin-top: 2px; }
h3 { margin: 0; font-size: 16px; font-weight: 800; }
p { margin: 2px 0 0; font-size: 12px; color: var(--text-muted); }
}
.toolbar-controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
label { font-size: 12px; font-weight: 700; color: var(--text-muted); white-space: nowrap; }
}
.form-select-sm {
padding: 4px 28px 4px 10px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 12px;
font-weight: 700;
background-color: #f8fafc;
cursor: pointer;
&:focus { border-color: var(--brand); outline: none; }
}
.divider-v {
width: 1px; height: 24px; background: rgba(0,0,0,0.1);
}
.btn-link {
font-size: 12px;
font-weight: 700;
color: var(--brand);
text-decoration: none;
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 12px;
border-radius: 8px;
background: var(--brand-soft);
transition: all 0.2s;
&:hover { background: rgba(227, 61, 207, 0.15); transform: translateX(2px); }
}
.card-body-grid {
padding: 20px;
position: relative;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
@media(max-width: 820px) { grid-template-columns: 1fr; }
}
.mini-chart-card {
background: #fdfdfd;
border: 1px solid rgba(0,0,0,0.05);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
h6 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
text-align: center;
}
}
.mini-metric-card {
.metric-stack {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 2px 2px;
}
.metric-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.06);
span {
font-size: 12px;
color: var(--text-muted);
font-weight: 700;
}
strong {
font-size: 13px;
color: var(--text-main);
font-weight: 800;
text-align: right;
}
}
}
.chart-area {
position: relative;
height: 280px;
width: 100%;
}
.loading-overlay {
position: absolute; inset: 0; background: rgba(255,255,255,0.8); z-index: 10;
display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px);
}
.error-state {
padding: 40px; text-align: center; color: #d97706; font-weight: 600;
display: flex; flex-direction: column; align-items: center; gap: 8px;
i { font-size: 24px; }
}
/* ========================================================== */
/* SEÇÃO 3: GRIDS FINAIS */
/* ========================================================== */
.grid-thirds {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
@media(max-width: 1000px) { grid-template-columns: 1fr; }
}
.grid-halves {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
@media(max-width: 800px) { grid-template-columns: 1fr; }
}
.grid-triples {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
@media(max-width: 1100px) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media(max-width: 800px) { grid-template-columns: 1fr; }
}
.history-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
@media(max-width: 1080px) { grid-template-columns: 1fr; }
}
/* Utils */
.text-brand { color: var(--brand); }
.text-brand-dark { color: #b832a8; }
.full-width { width: 100%; }

File diff suppressed because it is too large Load Diff

View File

@ -115,23 +115,23 @@
<!-- KPIs -->
<div class="fat-kpis mt-4 animate-fade-in">
<div class="kpi">
<span class="lbl">Total Clientes</span>
<div class="kpi kpi-stack kpi-stack-tight">
<span class="lbl">Clientes Faturados</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
</span>
</div>
<div class="kpi">
<span class="lbl">Total Linhas</span>
<div class="kpi kpi-stack kpi-stack-tight">
<span class="lbl">Linhas Faturadas</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
</span>
</div>
<div class="kpi kpi-wide">
<div class="kpi kpi-wide kpi-stack">
<span class="lbl text-vivo">Total Vivo</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
@ -139,7 +139,7 @@
</span>
</div>
<div class="kpi kpi-wide">
<div class="kpi kpi-wide kpi-stack">
<span class="lbl text-line">Total Line</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
@ -147,7 +147,7 @@
</span>
</div>
<div class="kpi">
<div class="kpi kpi-stack kpi-stack-tight">
<span class="lbl text-brand">Lucro</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
@ -168,7 +168,7 @@
<input
class="form-control"
placeholder="Pesquisar por cliente, aparelho, forma de pagamento..."
placeholder="Pesquisar..."
[(ngModel)]="searchTerm"
(ngModelChange)="onSearch()" />
@ -183,13 +183,8 @@
</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -229,7 +224,7 @@
</div>
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<div class="group-actions-row">
<small class="text-muted fw-bold">Registros do Cliente</small>
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Clique no “olho” para ver todos os detalhes</span>
</div>
@ -238,35 +233,35 @@
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr class="thead-group">
<th rowspan="2" class="sortable" (click)="setSort('item')">
<div class="th-content">ITEM <span class="sort-caret" [class.active]="sortBy==='item'">{{ sortBy==='item' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<th rowspan="2" class="sortable th-item" (click)="setSort('item')">
<div class="th-content">ITEM</div>
</th>
<th rowspan="2" class="sortable" (click)="setSort('qtdlinhas')">
<div class="th-content">QTD LINHAS <span class="sort-caret" [class.active]="sortBy==='qtdlinhas'">{{ sortBy==='qtdlinhas' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<div class="th-content">QTD LINHAS</div>
</th>
<th colspan="2" class="th-block th-vivo">VIVO</th>
<th colspan="2" class="th-block th-line">LINE MÓVEL</th>
<th rowspan="2">AÇÕES</th>
<th rowspan="2" class="actions-col">AÇÕES</th>
</tr>
<tr class="thead-sub">
<th class="sortable" (click)="setSort('franquiavivo')">
<div class="th-content">FRANQUIA <span class="sort-caret" [class.active]="sortBy==='franquiavivo'">{{ sortBy==='franquiavivo' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<div class="th-content">FRANQUIA</div>
</th>
<th class="sortable" (click)="setSort('valorcontratovivo')">
<div class="th-content">VALOR (R$) <span class="sort-caret" [class.active]="sortBy==='valorcontratovivo'">{{ sortBy==='valorcontratovivo' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<div class="th-content">VALOR (R$)</div>
</th>
<th class="sortable" (click)="setSort('franquialine')">
<div class="th-content">FRANQUIA <span class="sort-caret" [class.active]="sortBy==='franquialine'">{{ sortBy==='franquialine' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<div class="th-content">FRANQUIA</div>
</th>
<th class="sortable" (click)="setSort('valorcontratoline')">
<div class="th-content">VALOR (R$) <span class="sort-caret" [class.active]="sortBy==='valorcontratoline'">{{ sortBy==='valorcontratoline' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
<div class="th-content">VALOR (R$)</div>
</th>
</tr>
</thead>
@ -291,6 +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>
</div>
</td>
</tr>
@ -330,15 +327,15 @@
</section>
<!-- MODAIS -->
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()"></div>
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()"></div>
<div class="modal-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()">
<div class="modal-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()">
<!-- DETAIL MODAL -->
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-receipt"></i></span>
<span class="icon-bg detail-icon"><i class="bi bi-receipt"></i></span>
Detalhes do Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
@ -347,24 +344,16 @@
<div class="modal-body modern-body bg-light-gray" *ngIf="detailData; else detailLoading">
<div class="mb-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex flex-column">
<div class="fw-black" style="font-size: 1.05rem;">
<div class="fw-black detail-client">
{{ detailData.cliente || '—' }}
</div>
<small class="text-muted fw-bold">
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
</small>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge-pill vivo"><i class="bi bi-telephone-fill me-1"></i> {{ formatMoney(detailData.valorContratoVivo) }}</span>
<span class="badge-pill line"><i class="bi bi-hdd-network-fill me-1"></i> {{ formatMoney(detailData.valorContratoLine) }}</span>
<span class="badge-pill lucro" *ngIf="hasLucro(detailData)">
<i class="bi bi-cash-stack me-1"></i> {{ formatMoney(detailData.lucro) }}
</span>
</div>
</div>
<div class="details-dashboard details-2col">
<div class="details-dashboard details-single">
<!-- IDENTIFICAÇÃO -->
<div class="detail-box">
@ -400,77 +389,8 @@
</div>
</div>
</div>
<!-- VIVO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-telephone-fill me-2"></i> Vivo</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Franquia Vivo</span>
<span class="val fw-black">{{ formatFranquia(detailData.franquiaVivo) }}</span>
</div>
<div class="info-item">
<span class="lbl text-vivo">Valor Vivo (R$)</span>
<span class="val fw-black text-vivo">{{ formatMoney(detailData.valorContratoVivo) }}</span>
</div>
</div>
</div>
</div>
<!-- LINE -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-hdd-network-fill me-2"></i> Line Móvel</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Franquia Line</span>
<span class="val fw-black">{{ formatFranquia(detailData.franquiaLine) }}</span>
</div>
<div class="info-item">
<span class="lbl text-line">Valor Line (R$)</span>
<span class="val fw-black text-line">{{ formatMoney(detailData.valorContratoLine) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl text-brand">Lucro</span>
<span class="val fw-black text-brand">{{ formatMoney(detailData.lucro) }}</span>
</div>
</div>
</div>
</div>
<!-- RESUMO -->
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-info-circle me-2"></i> Resumo</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Observação</span>
<span class="val">{{ getObservacao(detailData) }}</span>
</div>
</div>
<div class="mt-3 d-flex justify-content-end gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" (click)="closeAllModals()">
Fechar
</button>
<button class="btn btn-primary btn-sm" (click)="onComparativo(detailData)">
<i class="bi bi-columns-gap me-1"></i> Abrir Comparativo
</button>
</div>
</div>
</div>
</div>
</div>
<ng-template #detailLoading>
@ -482,7 +402,7 @@
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg success"><i class="bi bi-columns-gap"></i></span> Comparativo Vivo x Line
<span class="icon-bg compare-icon"><i class="bi bi-columns-gap"></i></span> Comparativo Vivo x Line
</div>
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
</div>
@ -528,4 +448,127 @@
</ng-template>
</div>
<!-- EDIT MODAL -->
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span> Editar Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
</div>
<div class="form-field">
<label>Tipo</label>
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipo">
<option value="PF">PF</option>
<option value="PJ">PJ</option>
</select>
</div>
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" />
</div>
<div class="form-field">
<label>Qtd Linhas</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.qtdLinhas" />
</div>
<div class="form-field">
<label>Aparelho</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelho" />
</div>
<div class="form-field span-2">
<label>Forma de Pagamento</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.formaPagamento" />
</div>
</div>
</div>
</details>
<details open class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Faturamento Vivo</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">
<label>Franquia Vivo</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaVivo" />
</div>
<div class="form-field">
<label>Valor Vivo (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoVivo" />
</div>
</div>
</div>
</details>
<details open class="detail-box line-border">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Faturamento Line</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">
<label>Franquia Line</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaLine" />
</div>
<div class="form-field">
<label>Valor Line (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoLine" />
</div>
<div class="form-field span-2">
<label>Lucro (R$)</label>
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.lucro" />
</div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
<!-- DELETE MODAL -->
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span> Remover Faturamento
</div>
<button class="btn btn-sm btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma a exclusão do registro <strong>{{ deleteTarget?.cliente }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top bg-white">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>

View File

@ -80,7 +80,7 @@
.container-fat {
width: 100%;
max-width: 1180px;
max-width: 1240px;
position: relative;
z-index: 1;
margin-top: var(--page-top-gap);
@ -97,8 +97,9 @@
position: relative;
display: flex;
flex-direction: column;
max-height: calc(100vh - 18px) !important;
min-height: 0;
height: auto !important;
min-height: 80vh;
max-height: none !important;
&::before {
content: '';
@ -173,6 +174,22 @@
font-weight: 700;
}
.btn-glass {
border-radius: 12px;
font-weight: 900;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(3, 15, 170, 0.24);
color: var(--blue);
transition: all 0.2s ease;
&:hover {
background: #fff;
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
}
}
/* FILTERS */
.filters-row {
display: flex;
@ -321,7 +338,7 @@
}
.search-group {
max-width: 360px;
max-width: 270px;
border-radius: 12px;
overflow: hidden;
display: flex;
@ -448,6 +465,40 @@
}
}
.kpi-stack {
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
.lbl,
.val {
white-space: normal;
}
.val {
line-height: 1.1;
}
}
.kpi-stack-tight {
gap: 2px;
}
.kpi-compact {
padding: 6px 12px;
min-height: 56px;
align-items: center;
.val {
font-size: 1rem;
}
.lbl {
font-size: 0.68rem;
}
}
.kpi-wide {
min-width: 220px;
padding: 14px 18px;
@ -471,6 +522,8 @@
.groups-container {
padding: 16px;
overflow-y: auto;
flex: 1;
min-height: 0;
height: 100%;
}
@ -552,6 +605,16 @@
to { opacity: 1; transform: translateY(0); }
}
.group-actions-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid rgba(17,18,20,0.06);
gap: 12px;
}
.chip-muted {
display: inline-flex;
align-items: center;
@ -565,7 +628,11 @@
border: 1px solid rgba(17,18,20,0.06);
}
.inner-table-wrap { max-height: 520px; overflow: auto; }
.inner-table-wrap {
max-height: none;
height: auto;
overflow-y: visible;
}
/* TABLE */
.table-wrap { overflow: auto; height: 100%; }
@ -591,6 +658,9 @@
text-transform: uppercase;
white-space: nowrap;
text-align: center !important;
transition: color 0.2s ease;
&:hover { color: var(--brand); }
}
tbody tr {
@ -614,9 +684,19 @@
.sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } }
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
.empty-state { background: rgba(255,255,255,0.4); }
.th-item .th-content { justify-content: center; }
/* ACTIONS */
.action-group { display: flex; justify-content: center; gap: 6px; }
.actions-col { min-width: 152px; }
.action-group {
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.btn-icon {
width: 32px;
@ -632,6 +712,8 @@
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
&.success:hover { color: var(--success-text); background: var(--success-bg); }
&.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
}
/* FOOTER */
@ -698,8 +780,9 @@
overflow: hidden;
display: flex;
flex-direction: column;
width: min(900px, 100%);
width: min(850px, 100%);
max-height: 90vh;
min-height: 0;
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@ -734,17 +817,140 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
&.success { background: var(--success-bg); color: var(--success-text); }
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
}
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } }
}
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-footer { flex-shrink: 0; }
@media (max-width: 700px) {
.modal-card { border-radius: 16px; }
.modal-header { padding: 12px 16px; }
.modal-body { padding: 16px; }
}
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.edit-sections { display: grid; gap: 12px; }
.edit-sections details.detail-box {
border: 1px solid rgba(17, 18, 20, 0.08);
box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06);
overflow: hidden;
}
summary.box-header {
padding: 10px 16px;
font-size: 0.76rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(17, 18, 20, 0.08);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
display: flex;
align-items: center;
cursor: pointer;
list-style: none;
user-select: none;
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
&::-webkit-details-marker { display: none; }
}
.transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); }
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
.header-vivo {
color: #b91f9b;
background: linear-gradient(135deg, rgba(227, 61, 207, 0.14), rgba(248, 250, 252, 0.96));
}
.header-line {
color: var(--blue);
background: linear-gradient(135deg, rgba(3, 15, 170, 0.1), rgba(248, 250, 252, 0.96));
}
.vivo-border { border-top: 3px solid rgba(227, 61, 207, 0.45); }
.line-border { border-top: 3px solid rgba(3, 15, 170, 0.45); }
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
&.span-2 { grid-column: span 2; }
label {
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.64);
}
}
.form-control,
.form-select {
border-radius: 10px;
border: 1px solid rgba(17, 18, 20, 0.15);
background-color: #fff;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
&:hover { border-color: rgba(17, 18, 20, 0.38); }
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
outline: none;
transform: translateY(-1px);
}
}
.confirm-delete {
border: 1px solid rgba(220, 53, 69, 0.16);
background: #fff;
border-radius: 14px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 12px;
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
}
.confirm-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(220, 53, 69, 0.12);
color: #dc3545;
flex-shrink: 0;
}
/* detalhes e comparativo (mantidos) */
.details-dashboard {
display: grid;
@ -756,6 +962,12 @@
}
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
.details-single { grid-template-columns: minmax(0, 1fr); }
.detail-client {
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.detail-box {
background: #fff;
@ -765,10 +977,22 @@
overflow: hidden;
}
.icon-bg.detail-icon {
background: linear-gradient(135deg, rgba(67, 56, 202, 0.18), rgba(14, 165, 233, 0.16));
color: #1d4ed8;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.7), 0 10px 22px rgba(29, 78, 216, 0.2);
}
.icon-bg.compare-icon {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.22), rgba(59, 130, 246, 0.2));
color: #0f766e;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.7), 0 10px 24px rgba(16, 185, 129, 0.25);
}
.box-header.justify-content-center {
justify-content: center !important;
text-align: center;
background: rgba(227, 61, 207, 0.04);
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
color: var(--brand);
padding: 8px;

View File

@ -12,6 +12,7 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import {
BillingService,
@ -19,8 +20,11 @@ import {
BillingSortBy,
SortDir,
TipoCliente,
TipoFiltro
TipoFiltro,
BillingUpdateRequest
} from '../../services/billing';
import { AuthService } from '../../services/auth.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
interface BillingClientGroup {
cliente: string;
@ -33,7 +37,7 @@ interface BillingClientGroup {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent],
templateUrl: './faturamento.html',
styleUrls: ['./faturamento.scss']
})
@ -47,7 +51,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private billing: BillingService,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private authService: AuthService
) {}
loading = false;
@ -68,6 +73,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// pagina por CLIENTES (grupos)
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0; // total de grupos
// agrupamento
@ -90,6 +96,14 @@ export class Faturamento implements AfterViewInit, OnDestroy {
compareOpen = false;
detailData: BillingItem | null = null;
compareData: BillingItem | null = null;
editOpen = false;
editSaving = false;
editModel: BillingItem | null = null;
editingId: string | null = null;
deleteOpen = false;
deleteTarget: BillingItem | null = null;
isAdmin = false;
private searchTimer: any = null;
@ -118,20 +132,21 @@ export class Faturamento implements AfterViewInit, OnDestroy {
}
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(ev: KeyboardEvent) {
onDocumentKeydown(ev: Event) {
if (!isPlatformBrowser(this.platformId)) return;
if (ev.key === 'Escape') {
const keyboard = ev as KeyboardEvent;
if (keyboard.key === 'Escape') {
if (this.anyModalOpen()) {
ev.preventDefault();
ev.stopPropagation();
keyboard.preventDefault();
keyboard.stopPropagation();
this.closeAllModals();
return;
}
if (this.showClientMenu) {
this.showClientMenu = false;
ev.stopPropagation();
keyboard.stopPropagation();
this.cdr.detectChanges();
}
}
@ -145,6 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
this.isAdmin = this.authService.hasRole('sysadmin');
setTimeout(() => {
this.refreshData(true);
@ -163,7 +179,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// Helpers
// --------------------------
private anyModalOpen(): boolean {
return !!(this.detailOpen || this.compareOpen);
return !!(this.detailOpen || this.compareOpen || this.editOpen || this.deleteOpen);
}
closeAllModals() {
@ -171,6 +187,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
this.compareOpen = false;
this.detailData = null;
this.compareData = null;
this.editOpen = false;
this.editModel = null;
this.editingId = null;
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges();
}
@ -202,6 +223,24 @@ export class Faturamento implements AfterViewInit, OnDestroy {
.replace(/[\u0300-\u036f]/g, '');
}
private buildGlobalSearchBlob(row: BillingItem): string {
const parts = [
row.tipo,
row.item,
row.cliente,
row.qtdLinhas,
row.franquiaVivo,
row.valorContratoVivo,
row.franquiaLine,
row.valorContratoLine,
row.lucro,
row.aparelho,
row.formaPagamento,
];
return this.normalizeText(parts.join(' '));
}
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
if (filtro === 'ALL') return true;
@ -473,14 +512,9 @@ export class Faturamento implements AfterViewInit, OnDestroy {
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
}
const term = (this.searchTerm ?? '').trim().toLowerCase();
const term = this.normalizeText(this.searchTerm);
if (term) {
arr = arr.filter((r) => {
const cliente = (r.cliente ?? '').toLowerCase();
const aparelho = (r.aparelho ?? '').toLowerCase();
const forma = (r.formaPagamento ?? '').toLowerCase();
return cliente.includes(term) || aparelho.includes(term) || forma.includes(term);
});
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
}
// KPIs
@ -489,15 +523,35 @@ export class Faturamento implements AfterViewInit, OnDestroy {
let totalVivo = 0;
let totalLine = 0;
let totalLucro = 0;
const clientTotals = new Map<string, { vivo: number; line: number; lucro: number }>();
for (const r of arr) {
const c = (r.cliente ?? '').trim();
if (c) unique.add(c);
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
totalVivo += Number(r.valorContratoVivo ?? 0) || 0;
totalLine += Number(r.valorContratoLine ?? 0) || 0;
totalLucro += Number((r as any).lucro ?? 0) || 0;
const key = this.normalizeText(c);
if (!key) continue;
const vivo = Number(r.valorContratoVivo ?? 0) || 0;
const line = Number(r.valorContratoLine ?? 0) || 0;
const lucro = Number((r as any).lucro ?? 0) || 0;
const existing = clientTotals.get(key);
if (!existing) {
clientTotals.set(key, { vivo, line, lucro });
} else {
if (!existing.vivo && vivo) existing.vivo = vivo;
if (!existing.line && line) existing.line = line;
if (!existing.lucro && lucro) existing.lucro = lucro;
}
}
for (const vals of clientTotals.values()) {
totalVivo += vals.vivo;
totalLine += vals.line;
totalLucro += vals.lucro;
}
this.kpiTotalClientes = unique.size;
@ -595,8 +649,17 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// --------------------------
onDetalhes(r: BillingItem) {
this.detailOpen = true;
this.detailData = r;
this.cdr.detectChanges();
this.detailData = null;
this.billing.getById(r.id).subscribe({
next: (data) => {
this.detailData = data ?? r;
this.cdr.detectChanges();
},
error: () => {
this.detailData = r;
this.cdr.detectChanges();
}
});
}
onComparativo(r: BillingItem) {
@ -604,4 +667,87 @@ export class Faturamento implements AfterViewInit, OnDestroy {
this.compareData = r;
this.cdr.detectChanges();
}
onEditar(r: BillingItem) {
if (!this.isAdmin) return;
this.editingId = r.id;
this.editModel = { ...r };
this.editOpen = true;
this.cdr.detectChanges();
}
closeEdit() {
this.editOpen = false;
this.editModel = null;
this.editingId = null;
this.cdr.detectChanges();
}
onDelete(r: BillingItem) {
if (!this.isAdmin) return;
this.deleteTarget = r;
this.deleteOpen = true;
this.cdr.detectChanges();
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges();
}
async confirmDelete() {
if (!this.deleteTarget) return;
if (!(await confirmDeletionWithTyping('este registro de faturamento'))) return;
const id = this.deleteTarget.id;
this.billing.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.refreshData(true);
this.cdr.detectChanges();
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.cdr.detectChanges();
}
});
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const payload: BillingUpdateRequest = {
tipo: this.editModel.tipo,
item: this.toNullableNumber(this.editModel.item) as number | null,
cliente: (this.editModel.cliente ?? '').toString(),
qtdLinhas: this.toNullableNumber(this.editModel.qtdLinhas),
franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo),
valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo),
franquiaLine: this.toNullableNumber(this.editModel.franquiaLine),
valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine),
lucro: this.toNullableNumber(this.editModel.lucro),
aparelho: this.editModel.aparelho ?? null,
formaPagamento: this.editModel.formaPagamento ?? null
};
this.billing.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.refreshData(true);
},
error: () => {
this.editSaving = false;
}
});
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
}

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

@ -41,13 +41,13 @@
/* 2. LAYOUT DA PÁGINA (Vertical Destravado) */
/* ========================================================== */
.geral-page {
min-height: 100vh;
padding: 0 12px var(--page-bottom-gap);
min-height: 100dvh;
padding: var(--page-top-gap) 12px var(--page-bottom-gap);
display: flex;
align-items: flex-start;
justify-content: center;
position: relative;
overflow-y: auto; /* Scroll na janela */
overflow: visible;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
@ -80,7 +80,7 @@
max-width: 1100px; /* Largura controlada */
position: relative;
z-index: 1;
margin-top: var(--page-top-gap);
margin-top: 0;
margin-bottom: var(--page-bottom-gap);
margin-left: auto; margin-right: auto;
}
@ -138,9 +138,123 @@
.dropdown-list { overflow-y: auto; max-height: 300px; }
.dropdown-item-custom { padding: 10px 16px; font-size: 0.85rem; color: var(--text); cursor: pointer; border-bottom: 1px solid rgba(0,0,0,0.03); transition: background 0.1s; &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } }
.additional-filter-wrap {
position: relative;
}
.btn-additional-filter {
min-width: 160px;
max-width: 230px;
}
.additional-dropdown {
width: min(420px, calc(100vw - 24px));
max-height: 460px;
padding: 10px;
overflow: auto;
gap: 10px;
}
.additional-dropdown-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.additional-dropdown-title {
font-size: 0.74rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(17, 18, 20, 0.6);
}
.additional-dropdown-footer {
padding-top: 4px;
display: flex;
justify-content: flex-start;
}
.additional-mode-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.additional-mode-btn {
border: 1px solid rgba(17, 18, 20, 0.12);
background: rgba(255, 255, 255, 0.7);
color: var(--muted);
font-weight: 800;
font-size: 0.8rem;
border-radius: 999px;
padding: 6px 12px;
transition: all 0.2s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
background: #fff;
}
&.active {
border-color: var(--brand);
color: var(--brand);
background: #fff;
box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.additional-services-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.additional-chip-btn {
border: 1px solid rgba(17, 18, 20, 0.12);
background: rgba(255, 255, 255, 0.68);
color: var(--muted);
font-weight: 700;
font-size: 0.78rem;
border-radius: 999px;
padding: 5px 10px;
transition: all 0.2s ease;
&:hover {
border-color: var(--blue);
color: var(--blue);
background: #fff;
}
&.active {
border-color: var(--brand);
color: var(--brand);
background: rgba(227, 61, 207, 0.12);
}
&.clear {
color: var(--blue);
border-color: rgba(3, 15, 170, 0.18);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* 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; } }
.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; }
/* Insights */
/* Controls */
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
@ -166,9 +280,62 @@
.group-info { display: flex; flex-direction: column; gap: 6px; }
.group-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase; &.total { background: rgba(3,15,170,0.1); color: var(--blue); } &.ok { background: var(--success-bg); color: var(--success-text); } }
.group-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-pill { font-size: 0.65rem; padding: 4px 8px; border-radius: 999px; font-weight: 800; text-transform: uppercase; background: rgba(3,15,170,0.08); color: var(--blue); border: 1px solid rgba(3,15,170,0.16); }
.tag-pill.active { background: var(--success-bg); color: var(--success-text); border-color: rgba(25,135,84,0.22); }
.tag-pill.blocked { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(220,53,69,0.22); }
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
.client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.btn-add-line-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(227, 61, 207, 0.95), rgba(3, 15, 170, 0.95));
background-clip: padding-box;
box-shadow:
0 10px 22px rgba(3, 15, 170, 0.16),
inset 0 1px 0 rgba(255,255,255,0.2);
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
i {
font-weight: 900;
}
&:hover {
color: #fff;
transform: translateY(-1px);
filter: saturate(1.05) brightness(1.02);
box-shadow:
0 14px 26px rgba(227, 61, 207, 0.18),
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(227, 61, 207, 0.28),
0 12px 24px rgba(3, 15, 170, 0.16);
}
&:active {
transform: translateY(0);
box-shadow:
0 8px 16px rgba(3, 15, 170, 0.14),
inset 0 1px 0 rgba(255,255,255,0.16);
}
}
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
/* Inner Table Destravada */
@ -207,7 +374,10 @@
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } }
}
.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-create { width: min(1280px, 96vw); max-height: 92vh; }
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
@ -231,8 +401,8 @@
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
/* Caixas de Detalhes e Accordions simples */
details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: fit-content; &:not([open]) { padding-bottom: 0; } }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: fit-content; &:not([open]) { padding-bottom: 0; } }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: 100%; display: flex; flex-direction: column; }
summary.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; cursor: pointer; list-style: none; user-select: none; i:not(.transition-icon) { color: var(--brand); margin-right: 8px; } &::-webkit-details-marker { display: none; } .transition-icon { margin-left: auto; transition: transform 0.3s ease; font-size: 1rem; color: var(--muted); } }
details[open] summary .transition-icon { transform: rotate(180deg); color: var(--brand); }
@ -259,4 +429,82 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
/* === FORMULÁRIOS (GERAL) === */
.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); } }
.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

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Geral } from './geral';
@ -8,7 +11,12 @@ describe('Geral', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Geral]
imports: [Geral],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();
@ -20,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

@ -0,0 +1,259 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 10000;">
<div #successToast class="toast text-bg-danger border-0 shadow" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header border-bottom-0">
<strong class="me-auto text-primary">LineGestão</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Fechar"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark">
{{ toastMessage }}
</div>
</div>
</div>
<section class="historico-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>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-clock-history"></i> Auditoria
</div>
<div class="header-title">
<h5 class="title mb-0">Histórico</h5>
<small class="subtitle">Registros de alterações feitas no sistema.</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
</div>
<div class="filters-card mt-4">
<div class="filters-head">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros</span>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="applyFilters()" [disabled]="loading">
<i class="bi bi-check2"></i> Aplicar
</button>
<button class="btn-ghost" type="button" (click)="clearFilters()" [disabled]="loading">
<i class="bi bi-x-circle"></i> Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field filter-search">
<label>Período (De)</label>
<input type="date" [(ngModel)]="dateFrom" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Período (Até)</label>
<input type="date" [(ngModel)]="dateTo" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Página</label>
<app-select
class="select-glass"
size="sm"
[options]="pageOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterPageName"
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Ação</label>
<app-select
class="select-glass"
size="sm"
[options]="actionOptions"
labelKey="label"
valueKey="value"
placeholder="Todas"
[(ngModel)]="filterAction"
[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>
<div class="filter-field">
<label>Busca geral</label>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
</span>
<input
class="form-control"
placeholder="Pesquisar..."
[(ngModel)]="filterSearch"
(ngModelChange)="onSearchChange()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="filterSearch">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="geral-body">
<div class="table-wrap">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="alert alert-danger m-4" role="alert" *ngIf="!loading && error">
{{ errorMsg || 'Erro ao carregar histórico.' }}
<button class="btn btn-sm btn-outline-danger ms-3" type="button" (click)="refresh()">Tentar novamente</button>
</div>
<div class="empty-group" *ngIf="!loading && !error && logs.length === 0">
Nenhum log encontrado para os filtros atuais.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !error && logs.length > 0">
<thead>
<tr>
<th>Data/Hora</th>
<th>Usuário</th>
<th>Página</th>
<th>Ação</th>
<th>Item/Entidade</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let log of logs; trackBy: trackByLog">
<tr class="table-row-item" [class.expanded]="expandedLogId === log.id">
<td class="fw-bold text-muted">{{ formatDateTime(log.occurredAtUtc) }}</td>
<td>
<div class="user-cell">
<span class="user-name">{{ displayUserName(log) }}</span>
<small class="user-email">{{ log.userEmail || '-' }}</small>
</div>
</td>
<td class="td-clip" [title]="log.page">{{ log.page || '-' }}</td>
<td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td>
<td>
<div class="entity-cell">
<div class="entity-label td-clip" [title]="displayEntity(log)">
{{ displayEntity(log) }}
</div>
<button
class="expand-btn"
type="button"
(click)="toggleDetails(log, $event)"
[attr.aria-expanded]="expandedLogId === log.id"
[attr.aria-label]="expandedLogId === log.id ? 'Fechar detalhes' : 'Abrir detalhes'">
<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">
<td colspan="5">
<div class="details-panel">
<div class="details-section">
<div class="section-title">
<i class="bi bi-pencil-square"></i> Mudanças
</div>
<div class="changes-list" *ngIf="log.changes?.length; else noChanges">
<div class="change-item" *ngFor="let change of log.changes; trackBy: trackByField">
<div class="change-head">
<span class="change-field">{{ change.field }}</span>
<span class="change-type" [ngClass]="changeTypeClass(change.changeType)">
{{ changeTypeLabel(change.changeType) }}
</span>
</div>
<div class="change-values">
<span class="old">{{ formatChangeValue(change.oldValue) }}</span>
<i class="bi bi-arrow-right"></i>
<span class="new">{{ formatChangeValue(change.newValue) }}</span>
</div>
</div>
</div>
<ng-template #noChanges>
<div class="empty-state">Sem mudanças registradas.</div>
</ng-template>
</div>
<div class="details-section tech">
<div class="section-title">
<i class="bi bi-terminal"></i> Detalhes técnicos
</div>
<div class="tech-grid">
<div class="tech-item">
<span class="tech-label">Método</span>
<span class="tech-value">{{ log.requestMethod || '-' }}</span>
</div>
<div class="tech-item">
<span class="tech-label">Endpoint</span>
<span class="tech-value">{{ log.requestPath || '-' }}</span>
</div>
<div class="tech-item" *ngIf="log.ipAddress">
<span class="tech-label">IP</span>
<span class="tech-value">{{ log.ipAddress }}</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div class="geral-footer">
<div class="footer-meta">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} registros</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<app-select
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="pageSize"
(ngModelChange)="onPageSizeChange()"
[disabled]="loading">
</app-select>
</div>
</div>
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading">
<button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="page === totalPages || loading">
<button class="page-link" (click)="goToPage(page + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,275 @@
import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { HistoricoService, AuditLogDto, AuditChangeType, HistoricoQuery } from '../../services/historico.service';
interface SelectOption {
value: string;
label: string;
}
@Component({
selector: 'app-historico',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './historico.html',
styleUrls: ['./historico.scss'],
})
export class Historico implements OnInit {
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
logs: AuditLogDto[] = [];
loading = false;
error = false;
errorMsg = '';
toastMessage = '';
expandedLogId: string | null = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
filterPageName = '';
filterAction = '';
filterUserId = '';
filterSearch = '';
dateFrom = '';
dateTo = '';
readonly pageOptions: SelectOption[] = [
{ value: '', label: 'Todas as páginas' },
{ value: 'Geral', label: 'Geral' },
{ value: 'Mureg', label: 'Mureg' },
{ value: 'Faturamento', label: 'Faturamento' },
{ value: 'Parcelamentos', label: 'Parcelamentos' },
{ value: 'Dados e Usuários', label: 'Dados PF/PJ' },
{ value: 'Vigência', label: 'Vigência' },
{ value: 'Chips Virgens e Recebidos', label: 'Chips Virgens e Recebidos' },
{ value: 'Troca de número', label: 'Troca de número' },
];
readonly actionOptions: SelectOption[] = [
{ value: '', label: 'Todas as ações' },
{ value: 'CREATE', label: 'Criação' },
{ value: 'UPDATE', label: 'Atualização' },
{ value: 'DELETE', label: 'Exclusão' },
];
private searchTimer: any = null;
constructor(
private historicoService: HistoricoService,
private cdr: ChangeDetectorRef,
@Inject(PLATFORM_ID) private platformId: object
) {}
ngOnInit(): void {
this.fetch(1);
}
refresh(): void {
this.fetch(1);
}
applyFilters(): void {
this.page = 1;
this.fetch();
}
clearFilters(): void {
this.filterPageName = '';
this.filterAction = '';
this.filterUserId = '';
this.filterSearch = '';
this.dateFrom = '';
this.dateTo = '';
this.page = 1;
this.fetch();
}
clearSearch(): void {
this.filterSearch = '';
this.page = 1;
this.fetch();
}
onSearchChange(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1;
this.fetch();
}, 300);
}
onPageSizeChange(): void {
this.page = 1;
this.fetch();
}
goToPage(p: number): void {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.fetch();
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
}
get pageNumbers(): number[] {
const total = this.totalPages;
const current = this.page;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
toggleDetails(log: AuditLogDto, event?: Event): void {
if (event) event.stopPropagation();
this.expandedLogId = this.expandedLogId === log.id ? null : log.id;
}
formatDateTime(value?: string | null): string {
if (!value) return '-';
const d = new Date(value);
if (isNaN(d.getTime())) return '-';
return d.toLocaleString('pt-BR');
}
displayUserName(log: AuditLogDto): string {
const name = (log.userName || '').trim();
return name ? name : 'SISTEMA';
}
displayEntity(log: AuditLogDto): string {
const label = (log.entityLabel || '').trim();
if (label) return label;
return log.entityName || '-';
}
formatAction(action?: string | null): string {
const value = (action || '').toUpperCase();
if (!value) return '-';
if (value === 'CREATE') return 'Criação';
if (value === 'UPDATE') return 'Atualização';
if (value === 'DELETE') return 'Exclusão';
return 'Outro';
}
actionClass(action?: string | null): string {
const value = (action || '').toUpperCase();
if (value === 'CREATE') return 'action-create';
if (value === 'UPDATE') return 'action-update';
if (value === 'DELETE') return 'action-delete';
return 'action-default';
}
changeTypeLabel(type?: AuditChangeType | string | null): string {
if (!type) return 'Alterado';
if (type === 'added') return 'Adicionado';
if (type === 'removed') return 'Removido';
return 'Alterado';
}
changeTypeClass(type?: AuditChangeType | string | null): string {
if (type === 'added') return 'change-added';
if (type === 'removed') return 'change-removed';
if (type === 'modified') return 'change-modified';
return 'change-modified';
}
formatChangeValue(value?: string | null): string {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
trackByLog(_: number, log: AuditLogDto): string {
return log.id;
}
trackByField(_: number, change: { field: string }): string {
return change.field;
}
private fetch(goToPage?: number): void {
if (goToPage) this.page = goToPage;
this.loading = true;
this.error = false;
this.errorMsg = '';
this.expandedLogId = null;
const query: HistoricoQuery = {
page: this.page,
pageSize: this.pageSize,
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
userId: this.filterUserId?.trim() || undefined,
search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
};
this.historicoService.list(query).subscribe({
next: (res) => {
this.logs = res.items || [];
this.total = res.total || 0;
this.page = res.page || this.page;
this.pageSize = res.pageSize || this.pageSize;
this.loading = false;
},
error: (err: HttpErrorResponse) => {
this.error = true;
if (err?.status === 403) {
this.errorMsg = 'Acesso restrito.';
} else {
this.errorMsg = 'Erro ao carregar histórico. Tente novamente.';
}
this.loading = false;
},
});
}
private toIsoDate(value: string, endOfDay: boolean): string | null {
if (!value) return null;
const time = endOfDay ? '23:59:59' : '00:00:00';
const date = new Date(`${value}T${time}`);
if (isNaN(date.getTime())) return null;
return date.toISOString();
}
private async showToast(message: string) {
if (!isPlatformBrowser(this.platformId)) return;
this.toastMessage = message;
this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return;
try {
const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, {
autohide: true,
delay: 3000
});
toastInstance.show();
} catch (error) {
console.error(error);
}
}
}

View File

@ -1,13 +1,11 @@
import { Component, AfterViewInit, Inject, PLATFORM_ID } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { FeatureCardComponent } from '../../components/feature-card/feature-card';
import { CtaButtonComponent } from '../../components/cta-button/cta-button';
import { Router } from '@angular/router';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FeatureCardComponent, CtaButtonComponent],
imports: [CommonModule],
templateUrl: './home.html',
styleUrls: ['./home.scss'],
})

View File

@ -50,7 +50,6 @@
<span class="checkmark"></span>
Lembrar de mim
</label>
<a href="#" class="forgot-link">Esqueceu a senha?</a>
</div>
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">
@ -123,4 +122,4 @@
</div>
</div>
</div>
</div>

View File

@ -69,18 +69,6 @@ export class LoginComponent {
}
}
private saveToken(token: string) {
// ✅ SSR-safe
if (!isPlatformBrowser(this.platformId)) return;
// evita token antigo conflitar
localStorage.removeItem('token');
// Se quiser implementar a lógica de "Manter conectado", pode verificar o rememberMe aqui
// mas mantive a lógica original simples:
localStorage.setItem('token', token);
}
onSubmit(): void {
console.log('🚀 Iniciando login...');
this.apiError = '';
@ -94,7 +82,10 @@ export class LoginComponent {
this.isSubmitting = true;
const v = this.loginForm.value;
this.authService.login({ email: v.username, password: v.password }).subscribe({
this.authService.login(
{ email: v.username, password: v.password },
{ rememberMe: !!v.rememberMe }
).subscribe({
next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
console.log('✅ Resposta da API:', res);
this.isSubmitting = false;
@ -109,26 +100,34 @@ export class LoginComponent {
return;
}
console.log('🔑 Token encontrado. Salvando...');
this.saveToken(token);
this.authService.setToken(token, !!v.rememberMe);
const payload = this.authService.getTokenPayload();
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
if (!tenantId) {
this.apiError = 'Token invalido: tenantId ausente.';
this.authService.logout();
this.isSubmitting = false;
return;
}
// VERIFICAÇÃO 2: Decodificação
try {
const nome = this.getNameFromToken(token);
console.log('👤 Nome extraído:', nome);
console.log('🔄 Tentando ir para /geral...');
this.router.navigate(['/geral'], {
console.log('🔄 Tentando ir para /dashboard...');
this.router.navigate(['/dashboard'], {
state: { toastMessage: `Bem-vindo, ${nome}!` }
}).then(sucesso => {
if (sucesso) console.log('✅ Navegação funcionou!');
else console.error('❌ Navegação falhou! A rota "/geral" existe?');
else console.error('❌ Navegação falhou! A rota "/dashboard" existe?');
});
} catch (e) {
console.error('❌ Erro ao processar token ou navegar:', e);
// Força a ida mesmo se o nome falhar
this.router.navigate(['/geral']);
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
@ -145,4 +144,6 @@ export class LoginComponent {
if (error) return control.touched && control.hasError(error);
return !!(control.touched && control.invalid);
}
}
}

View File

@ -88,18 +88,7 @@
</span>
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="pageSize"
(change)="onPageSizeChange()"
[disabled]="loading"
>
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -181,9 +170,15 @@
</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon info" (click)="onView(r)" title="Ver Detalhes">
<i class="bi bi-eye"></i>
</button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn-icon danger" (click)="onDelete(r)" title="Excluir Registro">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
@ -223,7 +218,7 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen" (click)="closeEdit(); closeCreate()"></div>
<div class="modal-backdrop-custom" *ngIf="editOpen || createOpen || deleteOpen || detailOpen" (click)="closeEdit(); closeCreate(); closeDelete(); closeDetail()"></div>
<!-- ============================== -->
<!-- EDIT MODAL -->
@ -264,14 +259,7 @@
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.selectedClient"
(change)="onEditClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="editModel.selectedClient" (ngModelChange)="onEditClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
@ -281,19 +269,7 @@
<!-- Linha Antiga (select da Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.mobileLineId"
(change)="onEditLineChange()"
[disabled]="!editModel.selectedClient || editLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsEdit" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<app-select class="form-control" size="sm" [options]="lineOptionsEdit" labelKey="label" valueKey="id" [(ngModel)]="editModel.mobileLineId" (ngModelChange)="onEditLineChange()" [disabled]="!editModel.selectedClient || editLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
@ -388,14 +364,7 @@
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.selectedClient"
(change)="onCreateClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="createModel.selectedClient" (ngModelChange)="onCreateClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
@ -405,19 +374,7 @@
<!-- Linha Antiga (select Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.mobileLineId"
(change)="onCreateLineChange()"
[disabled]="!createModel.selectedClient || createLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsCreate" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<app-select class="form-control" size="sm" [options]="lineOptionsCreate" labelKey="label" valueKey="id" [(ngModel)]="createModel.mobileLineId" (ngModelChange)="onCreateLineChange()" [disabled]="!createModel.selectedClient || createLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
@ -467,3 +424,110 @@
</div>
</div>
<!-- ============================== -->
<!-- DETAIL MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="detailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-eye"></i></span>
Detalhes da Mureg
</div>
<button class="btn btn-sm btn-icon" (click)="closeDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="detailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!detailLoading && detailData">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Mureg</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Linha Nova</span>
<span class="val text-blue fs-4">{{ detailData.linhaNova || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Linha Antiga</span>
<span class="val">{{ detailData.linhaAntiga || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ detailData.cliente || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ detailData.usuario || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ detailData.item || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Data Mureg</span>
<span class="val">{{ displayValue('dataDaMureg', detailData.dataDaMureg) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">ICCID</span>
<span class="val small-text">{{ detailData.iccid || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Skil</span>
<span class="val">{{ detailData.skil || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================== -->
<!-- DELETE MODAL -->
<!-- ============================== -->
<div class="modal-custom" *ngIf="deleteOpen">
<div class="modal-card modal-sm" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Excluir Mureg
</div>
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
</div>
<div class="modal-body">
<p class="mb-2 fw-bold">Tem certeza que deseja excluir esta Mureg?</p>
<div class="text-muted small">
<div><strong>Cliente:</strong> {{ deleteTarget?.cliente || '-' }}</div>
<div><strong>Linha nova:</strong> {{ deleteTarget?.linhaNova || '-' }}</div>
<div><strong>Linha antiga:</strong> {{ deleteTarget?.linhaAntiga || '-' }}</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-glass btn-sm" (click)="closeDelete()" [disabled]="deleteSaving">
Cancelar
</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()" [disabled]="deleteSaving">
<span *ngIf="!deleteSaving"><i class="bi bi-trash me-1"></i> Excluir</span>
<span *ngIf="deleteSaving"><span class="spinner-border spinner-border-sm me-2"></span> Excluindo...</span>
</button>
</div>
</div>
</div>
</div>

View File

@ -267,6 +267,8 @@
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); }
&.info:hover { color: var(--brand); background: rgba(227, 61, 207, 0.12); }
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
}
/* FOOTER */
@ -278,15 +280,18 @@
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
.modal-card.modal-xl-custom { width: min(920px, 90vw); max-height: 78vh; }
.modal-card.modal-sm { width: min(480px, 100%); }
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px;
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); } /* Adicionado */
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
}
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } }
}
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body { padding: 20px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
/* FORM & DETAILS */
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
@ -294,6 +299,18 @@ div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0
div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
div.box-body { padding: 16px; }
/* INFO GRID (detalhes) */
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 0; }
.info-item { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 5px 8px; background: rgba(245, 245, 247, 0.5); border-radius: 10px; border: 1px solid rgba(0,0,0,0.03); transition: background 0.2s;
&:hover { background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
&.span-2 { grid-column: span 2; }
.lbl { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; color: var(--muted); margin-bottom: 2px; }
.val { font-size: 0.85rem; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.2;
&.fs-4 { font-size: 1rem !important; }
&.small-text { font-size: 0.7rem; font-family: monospace; }
}
}
/* EDIT FORM STYLES */
.form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
@ -307,4 +324,4 @@ div.box-body { padding: 16px; }
.form-control {
border-radius: 8px; border: 1px solid rgba(17,18,20,0.15);
&:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; }
}
}

View File

@ -9,8 +9,11 @@ import {
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
@ -50,6 +53,7 @@ interface LineOptionDto {
usuario: string | null;
cliente?: string | null;
skil?: string | null;
label?: string;
}
interface MuregDetailDto {
@ -73,7 +77,7 @@ interface MuregDetailDto {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './mureg.html',
styleUrls: ['./mureg.scss']
})
@ -90,7 +94,11 @@ export class Mureg implements AfterViewInit {
private linesService: LinesService
) {}
private readonly apiBase = 'https://localhost:7205/api/mureg';
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/mureg`;
})();
// ====== DATA ======
clientGroups: ClientGroup[] = [];
@ -109,6 +117,7 @@ export class Mureg implements AfterViewInit {
private searchTimer: any = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// ====== OPTIONS (GERAL) ======
@ -129,6 +138,16 @@ export class Mureg implements AfterViewInit {
editSaving = false;
editModel: any = null;
// ====== DETAIL MODAL ======
detailOpen = false;
detailLoading = false;
detailData: MuregDetailDto | null = null;
// ====== DELETE MODAL ======
deleteOpen = false;
deleteSaving = false;
deleteTarget: MuregRow | null = null;
// ====== CREATE MODAL ======
createOpen = false;
createSaving = false;
@ -401,7 +420,8 @@ export class Mureg implements AfterViewInit {
chip: x.chip ?? null,
usuario: x.usuario ?? null,
cliente: x.cliente ?? null,
skil: x.skil ?? null
skil: x.skil ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());
@ -638,6 +658,77 @@ export class Mureg implements AfterViewInit {
});
}
// =======================================================================
// DETAIL MODAL
// =======================================================================
onView(row: MuregRow) {
this.detailOpen = true;
this.detailLoading = true;
this.detailData = null;
this.http.get<MuregDetailDto>(`${this.apiBase}/${row.id}`).subscribe({
next: (data) => {
this.detailData = data;
this.detailLoading = false;
},
error: async () => {
this.detailLoading = false;
await this.showToast('Erro ao carregar detalhes da Mureg.');
}
});
}
closeDetail() {
this.detailOpen = false;
this.detailLoading = false;
this.detailData = null;
}
// =======================================================================
// DELETE MODAL
// =======================================================================
onDelete(row: MuregRow) {
this.deleteTarget = row;
this.deleteOpen = true;
this.deleteSaving = false;
}
closeDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
this.deleteSaving = false;
}
async confirmDelete() {
if (!this.deleteTarget?.id) return;
if (!(await confirmDeletionWithTyping('esta Mureg'))) return;
this.deleteSaving = true;
const targetId = this.deleteTarget.id;
const currentGroup = this.expandedGroup;
this.http.delete(`${this.apiBase}/${targetId}`).subscribe({
next: async () => {
this.deleteSaving = false;
await this.showToast('Mureg excluída com sucesso!');
this.closeDelete();
this.loadForGroups();
if (currentGroup) {
setTimeout(() => {
this.expandedGroup = currentGroup;
this.toggleGroup(currentGroup);
}, 400);
}
},
error: async (err) => {
this.deleteSaving = false;
const msg = this.extractApiMessage(err) ?? 'Erro ao excluir Mureg.';
await this.showToast(msg);
}
});
}
// =======================================================================
// Helpers
// =======================================================================

View File

@ -0,0 +1,183 @@
<section class="notificacoes-page">
<div class="wrap">
<div class="main-container">
<div class="page-header">
<div class="header-text">
<h2>Central de Notificações</h2>
<p>Gerencie seus alertas de vencimento e avisos do sistema.</p>
</div>
<div class="filters-bar">
<button type="button" class="pill" [class.active]="filter === 'todas'" (click)="setFilter('todas')">
Todas
</button>
<button type="button" class="pill" [class.active]="filter === 'aVencer'" (click)="setFilter('aVencer')">
A vencer
<span class="count-badge" *ngIf="countByType('AVencer') > 0">{{ countByType('AVencer') }}</span>
</button>
<button type="button" class="pill" [class.active]="filter === 'vencidas'" (click)="setFilter('vencidas')">
Vencidas
<span class="count-badge danger" *ngIf="countByType('Vencido') > 0">{{ countByType('Vencido') }}</span>
</button>
<button type="button" class="pill" [class.active]="filter === 'lidas'" (click)="setFilter('lidas')">
Arquivadas / Lidas
</button>
</div>
<div class="search-row" *ngIf="!loading && !error">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
[(ngModel)]="search"
(ngModelChange)="clearSelection()"
/>
<button
type="button"
class="clear-btn"
*ngIf="search"
(click)="clearSearch()"
aria-label="Limpar busca"
>
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="bulk-actions-bar" *ngIf="!loading && !error">
<div class="bulk-left">
<label class="select-all" *ngIf="filteredNotifications.length > 0">
<input type="checkbox" [checked]="isAllSelected" (change)="toggleSelectAll()" />
<span>Selecionar todas</span>
</label>
<span class="bulk-count">
Mostrando {{ filteredNotifications.length }} notificações
<span class="bulk-selected" *ngIf="selectedIds.size > 0">• {{ selectedIds.size }} selecionada(s)</span>
</span>
</div>
<div class="bulk-actions" *ngIf="filter !== 'lidas'">
<button
type="button"
class="bulk-btn"
(click)="markAllAsRead()"
[disabled]="bulkLoading || filteredNotifications.length === 0"
>
<span *ngIf="!bulkLoading"><i class="bi bi-check2-all me-1"></i> Ler todas</span>
<span *ngIf="bulkLoading"><span class="spinner-border spinner-border-sm me-2"></span> Marcando...</span>
</button>
<button
type="button"
class="bulk-btn ghost"
(click)="exportNotifications()"
[disabled]="exportLoading || filteredNotifications.length === 0"
>
<span *ngIf="!exportLoading"><i class="bi bi-download me-1"></i> Exportar</span>
<span *ngIf="exportLoading"><span class="spinner-border spinner-border-sm me-2"></span> Exportando...</span>
</button>
</div>
<div class="bulk-actions" *ngIf="filter === 'lidas'">
<button
type="button"
class="bulk-btn"
(click)="markAllAsUnread()"
[disabled]="bulkUnreadLoading || filteredNotifications.length === 0"
>
<span *ngIf="!bulkUnreadLoading"><i class="bi bi-arrow-counterclockwise me-1"></i> Restaurar selecionadas/todas</span>
<span *ngIf="bulkUnreadLoading"><span class="spinner-border spinner-border-sm me-2"></span> Restaurando...</span>
</button>
</div>
</div>
</div>
<div class="state-container" *ngIf="loading">
<div class="spinner-border text-primary" role="status"></div>
<p>Atualizando...</p>
</div>
<div class="state-container error" *ngIf="!loading && error">
<i class="bi bi-wifi-off"></i>
<p>Não foi possível carregar as notificações.</p>
</div>
<div class="empty-state-large" *ngIf="!loading && !error && filteredNotifications.length === 0">
<div class="illustration">
<i class="bi bi-check-circle-fill"></i>
</div>
<h3>Tudo em dia!</h3>
<p *ngIf="filter === 'todas'">Não há notificações no momento.</p>
<p *ngIf="filter !== 'todas'">Nenhuma notificação neste filtro.</p>
</div>
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
<div
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'"
>
<div class="status-strip"></div>
<label class="item-select">
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
<span></span>
</label>
<div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i>
</div>
<div class="item-content">
<div class="content-top">
<h4 class="item-title">
{{ n.linha || 'Linha Desconhecida' }}
<span class="separator"></span>
<span class="item-client">{{ n.cliente || '-' }}</span>
</h4>
<div class="date-stack">
<span class="date-pill green">Efetivação: {{ formatDateLabel(n.dtEfetivacaoServico) }}</span>
<span class="date-pill red">Término: {{ formatDateLabel(n.dtTerminoFidelizacao) }}</span>
</div>
</div>
<div class="item-meta-grid">
<div class="meta-row">
<span class="meta-label">Conta</span>
<span class="meta-value">{{ n.conta || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Usuário</span>
<span class="meta-value">{{ n.usuario || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Plano</span>
<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>
</div>
</div>
</div>
<div class="item-actions">
<button
type="button"
class="btn-action"
[title]="n.lida ? 'Marcar como não lida' : 'Marcar como lida'"
(click)="n.lida ? markAsUnread(n) : markAsRead(n)"
>
<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>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,733 @@
@use 'sass:color';
/* Variáveis */
$bg-page: #f9fafb;
$white: #ffffff;
$primary: #1c38c9;
$danger: #ef4444;
$warning: #f59e0b;
$success: #10b981;
$text-main: #111827;
$text-secondary: #4b5563;
$border: #e5e7eb;
:host { display: block; background-color: $bg-page; min-height: 100vh; }
.wrap { padding: 80px 0 40px; /* espaço para o header fixo */ }
.main-container {
max-width: 900px; /* Mais estreito para leitura melhor */
margin: 0 auto;
padding: 0 20px;
}
/* HEADER */
.page-header {
margin-bottom: 32px;
text-align: center; /* Centralizado fica mais moderno */
h2 { font-size: 28px; font-weight: 800; color: $text-main; margin-bottom: 8px; letter-spacing: -0.5px; }
p { color: $text-secondary; font-size: 16px; margin-bottom: 24px; }
}
.bulk-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.bulk-actions-bar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.bulk-count {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bulk-selected {
color: $text-main;
}
.select-all {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
input {
width: 20px;
height: 20px;
accent-color: $primary;
}
}
.bulk-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.bulk-btn {
background: $white;
border: 1px solid $border;
padding: 8px 12px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
color: $text-main;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
cursor: pointer;
&:hover { border-color: $primary; color: $primary; }
&:disabled { opacity: 0.6; cursor: default; }
&.ghost { background: transparent; }
}
/* FILTROS (Estilo Tabs/Pills) */
.filters-bar {
display: inline-flex;
background: $white;
padding: 6px;
border-radius: 99px;
box-shadow: 0 2px 10px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05);
gap: 4px;
flex-wrap: wrap; justify-content: center;
}
.search-row {
margin-top: 14px;
display: flex;
justify-content: center;
}
.search-box {
width: min(720px, 100%);
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.08);
background: $white;
box-shadow: 0 2px 10px rgba(0,0,0,0.03);
i { color: $text-secondary; }
input {
border: none;
outline: none;
background: transparent;
width: 100%;
font-size: 14px;
color: $text-main;
}
}
.clear-btn {
border: none;
background: transparent;
color: $text-secondary;
padding: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover { background: rgba(0,0,0,0.04); color: $text-main; }
}
.pill {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 99px;
font-size: 13px; font-weight: 600; color: $text-secondary;
cursor: pointer;
transition: all 0.2s;
display: flex; align-items: center; gap: 6px;
&:hover { background: rgba(0,0,0,0.03); color: $text-main; }
&.active {
background: $text-main; color: $white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.count-badge {
background: rgba(255,255,255,0.2);
color: currentColor;
padding: 1px 6px; border-radius: 10px; font-size: 10px;
&.danger { background: $danger; color: #fff; }
}
}
/* STATES */
.state-container {
text-align: center; padding: 40px;
color: $text-secondary; font-weight: 500;
&.error { color: $danger; }
.spinner-border { margin-bottom: 12px; }
}
.empty-state-large {
text-align: center;
padding: 60px 20px;
.illustration {
font-size: 64px; color: $success; opacity: 0.2;
margin-bottom: 16px;
}
h3 { font-size: 20px; font-weight: 700; color: $text-main; }
p { color: $text-secondary; }
}
/* LISTA */
.notif-list {
display: flex; flex-direction: column; gap: 12px;
}
/* list-header-actions removido */
.list-item {
background: $white;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.04);
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
display: flex; align-items: flex-start;
padding: 16px;
position: relative;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.06);
.btn-action { opacity: 1; }
}
/* Status Colors */
&.is-danger .status-strip { background: $danger; }
&.is-warning .status-strip { background: $warning; }
&.is-read {
opacity: 0.7; background: #fcfcfc; box-shadow: none;
.status-strip { background: $border; }
&:hover { opacity: 1; }
}
}
.item-select {
margin-left: 8px;
margin-right: 8px;
min-width: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
align-self: center;
input {
width: 20px;
height: 20px;
accent-color: $primary;
}
}
.status-strip {
position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
}
.item-icon {
width: 40px; margin-left: 8px; margin-right: 16px;
font-size: 24px; display: flex; align-items: center; justify-content: center;
height: 40px;
.bi-x-circle-fill { color: $danger; }
.bi-clock-fill { color: $warning; }
}
.item-content { flex: 1; min-width: 0; }
.content-top {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 10px;
margin-bottom: 10px;
}
.item-title {
font-size: 16px;
font-weight: 800;
color: $text-main;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.separator { color: $text-secondary; }
.item-client { font-weight: 600; color: $text-secondary; }
.date-stack {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
min-width: 170px;
text-align: right;
}
.date-pill {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.4px;
&.green { background: rgba($success, 0.12); color: $success; }
&.red { background: rgba($danger, 0.12); color: $danger; }
}
.item-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.meta-row { display: flex; flex-direction: column; gap: 2px; }
.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: $text-secondary; font-weight: 700; }
.meta-value { font-size: 13px; font-weight: 600; color: $text-main; }
.badge-tag {
font-size: 10px; text-transform: uppercase; padding: 4px 8px; border-radius: 999px;
font-weight: 800; letter-spacing: 0.5px;
width: fit-content;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
}
.item-actions {
margin-left: 12px; align-self: center;
}
.btn-action {
background: white; border: 1px solid $border;
padding: 8px 12px; border-radius: 8px;
cursor: pointer;
color: $text-main; font-size: 13px; font-weight: 600;
display: flex; align-items: center; gap: 6px;
transition: all 0.2s;
&:hover { border-color: $primary; color: $primary; }
&:disabled { border-color: transparent; background: transparent; color: $success; cursor: default; }
/* Mobile optimization: show button usually only on hover desktop, always mobile */
@media(min-width: 768px) { opacity: 0.6; }
}
/* ==========================================================================
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%;
}
.btn-action {
width: 100%;
justify-content: center;
padding: 8px 10px;
border-radius: 10px;
font-size: 12px;
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: 11px;
padding: 7px 8px;
}
}

View File

@ -0,0 +1,370 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
@Component({
selector: 'app-notificacoes',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './notificacoes.html',
styleUrls: ['./notificacoes.scss'],
})
export class Notificacoes implements OnInit, OnDestroy {
notifications: NotificationDto[] = [];
filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas';
search = '';
loading = false;
error = false;
bulkLoading = false;
bulkUnreadLoading = false;
exportLoading = false;
selectedIds = new Set<string>();
private readonly subs = new Subscription();
constructor(private notificationsService: NotificationsService) {}
ngOnInit(): void {
this.loadNotifications();
this.subs.add(
this.notificationsService.events$.subscribe((ev) => {
if (ev.type === 'read') {
const ids = new Set(ev.ids);
this.notifications.forEach((n) => {
if (ids.has(n.id)) {
n.lida = true;
n.lidaEm = ev.readAtIso;
}
});
return;
}
if (ev.type === 'readAll') {
this.notifications.forEach((n) => {
if (!n.lida) {
n.lida = true;
n.lidaEm = ev.readAtIso;
}
});
return;
}
if (ev.type === 'unread') {
const ids = new Set(ev.ids);
this.notifications.forEach((n) => {
if (ids.has(n.id)) {
n.lida = false;
n.lidaEm = null;
}
});
return;
}
if (ev.type === 'unreadAll') {
this.notifications.forEach((n) => {
if (n.lida) {
n.lida = false;
n.lidaEm = null;
}
});
return;
}
if (ev.type === 'reload') {
this.loadNotifications();
}
})
);
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
markAsRead(notification: NotificationDto) {
if (notification.lida) return;
this.notificationsService.markAsRead(notification.id).subscribe({
next: () => {
notification.lida = true;
notification.lidaEm = new Date().toISOString();
},
});
}
markAsUnread(notification: NotificationDto) {
if (!notification.lida) return;
this.notificationsService.markAsUnread(notification.id).subscribe({
next: () => {
notification.lida = false;
notification.lidaEm = null;
},
});
}
setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') {
this.filter = value;
this.clearSelection();
}
get filteredNotifications() {
const base = this.getBaseFilteredNotifications();
const q = (this.search || '').trim().toLowerCase();
if (!q) return base;
return base.filter(n => this.buildSearchText(n).includes(q));
}
clearSearch() {
this.search = '';
this.clearSelection();
}
formatDateLabel(date?: string | null): string {
if (!date) return '-';
const parsed = this.parseDateOnly(date);
if (!parsed) return '-';
return parsed.toLocaleDateString('pt-BR');
}
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo;
const today = this.startOfDay(new Date());
return parsed < today ? 'Vencido' : 'AVencer';
}
private loadNotifications() {
this.loading = true;
this.error = false;
this.notificationsService.list().subscribe({
next: (data) => {
this.notifications = data || [];
this.loading = false;
},
error: () => {
this.error = true;
this.loading = false;
},
});
}
countByType(tipo: 'Vencido' | 'AVencer'): number {
return this.notifications.filter(n => this.getNotificationTipo(n) === tipo && !n.lida).length;
}
markAllAsRead() {
if (this.filter === 'lidas' || this.bulkLoading) return;
this.bulkLoading = true;
const selectedIds = Array.from(this.selectedIds);
const scopedIds = selectedIds.length
? selectedIds
: (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []);
const filterParam = scopedIds.length ? undefined : this.getFilterParam();
this.notificationsService.markAllAsRead(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({
next: () => {
const now = new Date().toISOString();
this.notifications = this.notifications.map((n) => {
if (scopedIds.length ? scopedIds.includes(n.id) : this.shouldMarkRead(n)) {
return { ...n, lida: true, lidaEm: now };
}
return n;
});
this.clearSelection();
this.bulkLoading = false;
},
error: () => {
this.bulkLoading = false;
}
});
}
markAllAsUnread() {
if (this.filter !== 'lidas' || this.bulkUnreadLoading) return;
this.bulkUnreadLoading = true;
const selectedIds = Array.from(this.selectedIds);
const scopedIds = selectedIds.length
? selectedIds
: this.filteredNotifications.map(n => n.id);
this.notificationsService.markAllAsUnread(undefined, scopedIds.length ? scopedIds : undefined).subscribe({
next: () => {
this.notifications = this.notifications.map((n) => {
if (scopedIds.length ? scopedIds.includes(n.id) : n.lida) {
return { ...n, lida: false, lidaEm: null };
}
return n;
});
this.clearSelection();
this.bulkUnreadLoading = false;
},
error: () => {
this.bulkUnreadLoading = false;
}
});
}
exportNotifications() {
if (this.filter === 'lidas' || this.exportLoading) return;
this.exportLoading = true;
const selectedIds = Array.from(this.selectedIds);
const scopedIds = selectedIds.length
? selectedIds
: (this.filter !== 'todas' ? this.filteredNotifications.map(n => n.id) : []);
const filterParam = scopedIds.length ? undefined : this.getFilterParam();
this.notificationsService.export(filterParam, scopedIds.length ? scopedIds : undefined).subscribe({
next: (res) => {
const blob = res.body;
if (!blob) {
this.exportLoading = false;
return;
}
const filename = this.extractFilename(res.headers.get('content-disposition')) || this.buildDefaultFilename();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.clearSelection();
this.exportLoading = false;
},
error: () => {
this.exportLoading = false;
}
});
}
isSelected(notification: NotificationDto): boolean {
return this.selectedIds.has(notification.id);
}
toggleSelection(notification: NotificationDto) {
if (this.selectedIds.has(notification.id)) {
this.selectedIds.delete(notification.id);
} else {
this.selectedIds.add(notification.id);
}
}
get isAllSelected(): boolean {
const list = this.filteredNotifications;
return list.length > 0 && list.every(n => this.selectedIds.has(n.id));
}
toggleSelectAll() {
const list = this.filteredNotifications;
if (this.isAllSelected) {
this.clearSelection();
return;
}
list.forEach(n => this.selectedIds.add(n.id));
}
clearSelection() {
this.selectedIds.clear();
}
private getFilterParam(): string | undefined {
if (this.filter === 'aVencer') return 'a-vencer';
if (this.filter === 'vencidas') return 'vencidas';
if (this.filter === 'todas') return undefined;
return undefined;
}
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';
return false;
}
private getBaseFilteredNotifications(): NotificationDto[] {
if (this.filter === 'lidas') {
return this.notifications.filter(n => n.lida);
}
if (this.filter === 'vencidas') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
}
if (this.filter === 'aVencer') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
}
// "todas" aqui representa o inbox: pendentes (não lidas).
return this.notifications.filter(n => !n.lida);
}
private buildSearchText(n: NotificationDto): string {
const parts: string[] = [];
const push = (v?: string | null) => {
const t = (v ?? '').toString().trim();
if (t) parts.push(t);
};
push(n.cliente);
push(n.conta);
push(n.linha);
push(n.usuario);
push(n.planoContrato);
push(n.titulo);
push(n.mensagem);
push(n.data);
push(n.referenciaData ?? null);
push(n.dtEfetivacaoServico ?? null);
push(n.dtTerminoFidelizacao ?? null);
const efetivacao = this.formatDateSearch(n.dtEfetivacaoServico);
const termino = this.formatDateSearch(n.dtTerminoFidelizacao);
push(efetivacao);
push(termino);
return parts.join(' ').toLowerCase();
}
private formatDateSearch(raw?: string | null): string {
if (!raw) return '';
const parsed = this.parseDateOnly(raw);
if (!parsed) return '';
// Ex.: 12/02/2026 (facilita busca por padrão BR).
return parsed.toLocaleDateString('pt-BR');
}
private parseDateOnly(raw?: string | null): Date | null {
if (!raw) return null;
const datePart = raw.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) {
const year = Number(parts[0]);
const month = Number(parts[1]);
const day = Number(parts[2]);
if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) {
return new Date(year, month - 1, day);
}
}
const fallback = new Date(raw);
if (Number.isNaN(fallback.getTime())) return null;
return this.startOfDay(fallback);
}
private startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
private extractFilename(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const normalMatch = contentDisposition.match(/filename=\"?([^\";]+)\"?/i);
return normalMatch?.[1] ?? null;
}
private buildDefaultFilename(): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '').slice(0, 14);
return `notificacoes-${stamp}.xlsx`;
}
}

View File

@ -0,0 +1,151 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-plus-circle"></i></span>
<span>{{ title }}</span>
</div>
<button class="btn-icon" type="button" (click)="close.emit()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<section class="form-section">
<div class="section-head">
<h4>Dados do parcelamento</h4>
<small>Preencha os campos obrigatorios para salvar o cadastro.</small>
</div>
<div class="form-grid">
<div class="form-field">
<label>Ano Ref *</label>
<input type="number" [(ngModel)]="model.anoRef" />
<small *ngIf="touched && !model.anoRef" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Item *</label>
<input type="number" [(ngModel)]="model.item" />
<small *ngIf="touched && !model.item" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Linha *</label>
<input type="text" [(ngModel)]="model.linha" />
<small *ngIf="touched && !model.linha" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Cliente *</label>
<input type="text" [(ngModel)]="model.cliente" />
<small *ngIf="touched && !model.cliente" class="error">Campo obrigatorio.</small>
</div>
<div class="form-field">
<label>Qt Parcelas</label>
<input type="text" placeholder="1/12" [(ngModel)]="model.qtParcelas" (ngModelChange)="onQtParcelasChange()" />
</div>
<div class="form-field">
<label>Parcela atual</label>
<input type="number" min="0" [(ngModel)]="model.parcelaAtual" (ngModelChange)="onParcelaChange()" />
</div>
<div class="form-field">
<label>Total de parcelas *</label>
<input type="number" min="1" [(ngModel)]="model.totalParcelas" (ngModelChange)="onParcelaChange(); onValueChange()" />
<small *ngIf="touched && (!model.totalParcelas || model.totalParcelas <= 0)" class="error">Informe a quantidade.</small>
</div>
<div class="form-field">
<label>Valor cheio *</label>
<input type="text" placeholder="0,00" [(ngModel)]="model.valorCheio" (ngModelChange)="onValueChange()" />
<small *ngIf="touched && !model.valorCheio" class="error">Informe o valor.</small>
</div>
<div class="form-field">
<label>Desconto</label>
<input type="text" placeholder="0,00" [(ngModel)]="model.desconto" (ngModelChange)="onValueChange()" />
</div>
<div class="form-field">
<label>Valor com desconto</label>
<input type="text" [(ngModel)]="model.valorComDesconto" (ngModelChange)="onValorComDescontoChange()" />
</div>
<div class="form-field">
<label>Competencia inicial *</label>
<div class="competencia-row">
<input type="number" placeholder="Ano" [(ngModel)]="model.competenciaAno" (ngModelChange)="onCompetenciaChange()" />
<app-select
class="select-glass"
size="sm"
[options]="monthOptions"
labelKey="label"
valueKey="value"
placeholder="Mes"
[(ngModel)]="model.competenciaMes"
(ngModelChange)="onCompetenciaChange()">
</app-select>
</div>
<small *ngIf="touched && (!model.competenciaAno || !model.competenciaMes)" class="error">Informe ano e mes.</small>
</div>
<div class="form-field final">
<label>Competencia final</label>
<div class="final-box">{{ competenciaFinalLabel }}</div>
</div>
</div>
</section>
<section class="preview-card">
<div class="preview-head">
<div>
<h4>Parcelas por competencia</h4>
<small>Edite os valores por mes (max 36 exibidas)</small>
</div>
</div>
<div class="preview-table">
<table>
<thead>
<tr>
<th>Mes/Ano</th>
<th>Parcela</th>
<th class="text-end">Valor</th>
</tr>
</thead>
<tbody>
<tr *ngIf="previewRows.length === 0">
<td colspan="3" class="empty">Preencha as informacoes para gerar a previa.</td>
</tr>
<tr *ngFor="let row of previewRows; trackBy: trackByPreview">
<td>{{ row.label }}</td>
<td>{{ row.parcela }}</td>
<td class="text-end">
<input
type="text"
class="inline-input"
[ngModel]="row.valor"
(ngModelChange)="onPreviewValueChange(row.competencia, $event)"
placeholder="0,00" />
</td>
</tr>
</tbody>
</table>
</div>
<p class="todo-note" *ngIf="errorMessage">{{ errorMessage }}</p>
</section>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="close.emit()">Cancelar</button>
<button class="btn-primary" type="button" [disabled]="loading" (click)="onSave()">
{{ loading ? 'Salvando...' : submitLabel }}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,389 @@
:host {
display: block;
--brand: var(--pg-primary, #1f4fd6);
--blue: var(--pg-primary-strong, #153caa);
--focus-ring: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.15), rgba(15, 23, 42, 0.66) 42%),
rgba(15, 23, 42, 0.58);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1040px, 96vw);
max-height: 92vh;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 18px;
border: 1px solid var(--pg-border, #dbe3ef);
background: #fff;
box-shadow: var(--pg-shadow-lg, 0 24px 56px rgba(15, 23, 42, 0.25));
animation: pop-up 0.24s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--pg-border, #dbe3ef);
background: linear-gradient(180deg, #f4f8ff, #ffffff 85%);
}
.modal-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 0.98rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
.icon-bg {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(31, 79, 214, 0.12);
color: var(--blue);
}
}
.btn-icon {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text-soft, #64748b);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--brand);
color: var(--blue);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 18px;
background: linear-gradient(180deg, #f8fbff, #ffffff 82%);
display: grid;
gap: 16px;
}
.form-section {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 14px;
padding: 12px;
background: #fff;
display: grid;
gap: 12px;
}
.section-head {
display: grid;
gap: 3px;
h4 {
margin: 0;
font-size: 0.9rem;
color: var(--pg-text, #0f172a);
font-weight: 800;
}
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
label {
color: var(--pg-text-soft, #64748b);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
}
input {
height: 40px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
padding: 0 12px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
.error {
color: var(--pg-danger, #c52929);
font-size: 11px;
font-weight: 700;
}
}
.competencia-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: center;
}
.select-glass {
background: #fff;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: 10px;
color: var(--pg-text, #0f172a);
font-weight: 700;
}
.final-box {
min-height: 40px;
border-radius: 10px;
border: 1px dashed var(--pg-border-strong, #c8d4e4);
background: #f8fbff;
color: var(--pg-text-muted, #475569);
padding: 0 12px;
display: flex;
align-items: center;
font-weight: 700;
}
.preview-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 14px;
padding: 12px;
display: grid;
gap: 10px;
background: #fff;
}
.preview-head h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
.preview-head small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
.preview-table {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: 10px;
max-height: 240px;
overflow: auto;
}
.preview-table table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 460px;
}
.preview-table th,
.preview-table td {
padding: 8px 10px;
border-bottom: 1px solid #e9eef6;
font-size: 12px;
color: var(--pg-text, #0f172a);
}
.preview-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f5f9ff;
color: var(--pg-text-soft, #64748b);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 10px;
font-weight: 800;
}
.preview-table .empty {
text-align: center;
color: var(--pg-text-muted, #475569);
font-weight: 700;
}
.inline-input {
width: 100%;
height: 32px;
border-radius: 8px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
text-align: right;
padding: 0 8px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
.todo-note {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(197, 41, 41, 0.28);
background: rgba(197, 41, 41, 0.1);
color: var(--pg-danger, #c52929);
font-size: 12px;
font-weight: 700;
}
.modal-footer {
border-top: 1px solid var(--pg-border, #dbe3ef);
padding: 12px 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #fff;
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: 10px;
border: 1px solid transparent;
padding: 0 14px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
}
}
.btn-primary {
color: #fff;
border-color: var(--blue);
background: linear-gradient(140deg, var(--brand), var(--blue));
box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28);
}
.btn-ghost {
color: var(--pg-text, #0f172a);
background: #fff;
border-color: var(--pg-border-strong, #c8d4e4);
}
.btn-primary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--brand);
color: var(--blue);
}
@keyframes pop-up {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 940px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.modal-header,
.modal-body,
.modal-footer {
padding-left: 12px;
padding-right: 12px;
}
.form-grid {
grid-template-columns: 1fr;
}
.modal-footer {
justify-content: stretch;
}
.modal-footer .btn-primary,
.modal-footer .btn-ghost {
flex: 1;
}
}

View File

@ -0,0 +1,253 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
export type MonthOption = { value: number; label: string };
export type ParcelamentoCreateModel = {
anoRef: number | null;
linha: string;
cliente: string;
item: number | null;
qtParcelas: string;
parcelaAtual: number | null;
totalParcelas: number | null;
valorCheio: string;
desconto: string;
valorComDesconto: string;
competenciaAno: number | null;
competenciaMes: number | null;
monthValues: Array<{ competencia: string; valor: string }>;
};
type PreviewRow = {
competencia: string;
label: string;
parcela: number;
valor: string;
};
@Component({
selector: 'app-parcelamento-create-modal',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './parcelamento-create-modal.html',
styleUrls: ['./parcelamento-create-modal.scss'],
})
export class ParcelamentoCreateModalComponent implements OnChanges {
@Input() open = false;
@Input() monthOptions: MonthOption[] = [];
@Input() model!: ParcelamentoCreateModel;
@Input() title = 'Novo Parcelamento';
@Input() submitLabel = 'Salvar';
@Input() loading = false;
@Input() errorMessage = '';
@Output() close = new EventEmitter<void>();
@Output() save = new EventEmitter<ParcelamentoCreateModel>();
touched = false;
previewRows: PreviewRow[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (changes['model'] && this.model) {
this.syncMonthValues();
return;
}
if (changes['open'] && this.model) {
this.rebuildPreviewRows();
}
}
onValueChange(): void {
const cheio = this.toNumber(this.model.valorCheio);
const desconto = this.toNumber(this.model.desconto);
if (cheio === null) {
this.model.valorComDesconto = '';
this.syncMonthValues();
return;
}
const calc = Math.max(0, cheio - (desconto ?? 0));
this.model.valorComDesconto = this.formatInput(calc);
this.syncMonthValues();
}
onCompetenciaChange(): void {
this.syncMonthValues();
}
onValorComDescontoChange(): void {
this.syncMonthValues();
}
onParcelaChange(): void {
this.syncQtParcelas();
this.syncMonthValues();
}
onQtParcelasChange(): void {
const parsed = this.parseQtParcelas(this.model.qtParcelas);
if (parsed) {
this.model.parcelaAtual = parsed.atual;
this.model.totalParcelas = parsed.total;
}
this.syncMonthValues();
}
get competenciaFinalLabel(): string {
if (this.model.monthValues?.length) {
const last = this.model.monthValues[this.model.monthValues.length - 1];
return this.formatCompetenciaLabel(last.competencia);
}
const total = this.model.totalParcelas ?? 0;
const ano = this.model.competenciaAno ?? 0;
const mes = this.model.competenciaMes ?? 0;
if (!total || !ano || !mes) return '-';
const index = (mes - 1) + (total - 1);
const finalAno = ano + Math.floor(index / 12);
const finalMes = (index % 12) + 1;
return `${String(finalMes).padStart(2, '0')}/${finalAno}`;
}
onPreviewValueChange(competencia: string, value: string): void {
const list = this.model.monthValues ?? [];
const item = list.find((entry) => entry.competencia === competencia);
if (item) item.valor = value ?? '';
const row = this.previewRows.find((entry) => entry.competencia === competencia);
if (row) row.valor = value ?? '';
}
trackByPreview(_: number, row: PreviewRow): string {
return row.competencia;
}
get isValid(): boolean {
return !!(
this.model.anoRef &&
this.model.item &&
this.model.linha?.trim() &&
this.model.cliente?.trim() &&
this.model.totalParcelas &&
this.model.totalParcelas > 0 &&
this.model.valorCheio &&
this.model.competenciaAno &&
this.model.competenciaMes
);
}
onSave(): void {
this.touched = true;
if (!this.isValid) return;
this.save.emit(this.model);
}
private syncQtParcelas(): void {
const atual = this.model.parcelaAtual;
const total = this.model.totalParcelas;
if (atual && total) {
this.model.qtParcelas = `${atual}/${total}`;
}
}
private syncMonthValues(): void {
const total = this.model.totalParcelas ?? 0;
const ano = this.model.competenciaAno ?? 0;
const mes = this.model.competenciaMes ?? 0;
if (!total || !ano || !mes) {
this.model.monthValues = [];
this.previewRows = [];
return;
}
const existing = new Map<string, string>();
(this.model.monthValues ?? []).forEach((m) => {
if (m?.competencia) existing.set(m.competencia, m.valor ?? '');
});
const valorTotal = this.toNumber(this.model.valorComDesconto) ?? this.toNumber(this.model.valorCheio);
const valorParcela = valorTotal !== null ? valorTotal / total : null;
const defaultValor = valorParcela !== null ? this.formatInput(valorParcela) : '';
const list: Array<{ competencia: string; valor: string }> = [];
for (let i = 0; i < total; i++) {
const index = (mes - 1) + i;
const y = ano + Math.floor(index / 12);
const m = (index % 12) + 1;
const competencia = `${y}-${String(m).padStart(2, '0')}-01`;
list.push({
competencia,
valor: existing.get(competencia) ?? defaultValor,
});
}
this.model.monthValues = list;
this.rebuildPreviewRows();
}
private rebuildPreviewRows(): void {
const list = this.model?.monthValues ?? [];
if (!list.length) {
this.previewRows = [];
return;
}
this.previewRows = list.slice(0, 36).map((item, idx) => ({
competencia: item.competencia,
label: this.formatCompetenciaLabel(item.competencia),
parcela: idx + 1,
valor: item.valor ?? '',
}));
}
private formatCompetenciaLabel(value: string): string {
const match = value.match(/^(\d{4})-(\d{2})/);
if (!match) return value || '-';
return `${match[2]}/${match[1]}`;
}
private parseQtParcelas(raw: string | null | undefined): { atual: number; total: number } | null {
if (!raw) return null;
const parts = raw.split('/');
if (parts.length < 2) return null;
const atualStr = this.onlyDigits(parts[0]);
const totalStr = this.onlyDigits(parts[1]);
if (!atualStr || !totalStr) return null;
return { atual: Number(atualStr), total: Number(totalStr) };
}
private toNumber(value: any): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const raw = String(value).trim();
if (!raw) return null;
let cleaned = raw.replace(/[^\d,.-]/g, '');
if (cleaned.includes(',') && cleaned.includes('.')) {
if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) {
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
cleaned = cleaned.replace(/,/g, '');
}
} else if (cleaned.includes(',')) {
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
cleaned = cleaned.replace(/,/g, '');
}
const n = Number(cleaned);
return Number.isNaN(n) ? null : n;
}
private onlyDigits(value: string): string {
let out = '';
for (const ch of value ?? '') {
if (ch >= '0' && ch <= '9') out += ch;
}
return out;
}
private formatInput(value: number): string {
return new Intl.NumberFormat('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
}
}

View File

@ -0,0 +1,56 @@
<div class="lg-backdrop" *ngIf="open" (click)="close.emit()"></div>
<div class="lg-modal" *ngIf="open">
<div class="lg-modal-card annual-card" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-table"></i></span>
<span>Detalhamento Completo - {{ selectedYear }}</span>
</div>
<div class="modal-actions">
<select [ngModel]="selectedYear" (ngModelChange)="onYearChange($event)">
<option *ngFor="let y of years" [value]="y">{{ y }}</option>
</select>
<button class="btn-icon" type="button" (click)="close.emit()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="annual-table" *ngIf="data; else emptyState">
<table>
<thead>
<tr>
<th class="sticky-col col-1">Cliente</th>
<th class="sticky-col col-2">Linha</th>
<th class="sticky-col col-3">Item</th>
<th class="sticky-col col-4 text-end">Total</th>
<th class="sticky-col col-5">Parc.</th>
<th *ngFor="let m of data.months" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky-col col-1">{{ data.cliente || '-' }}</td>
<td class="sticky-col col-2">{{ data.linha || '-' }}</td>
<td class="sticky-col col-3">{{ data.item || '-' }}</td>
<td class="sticky-col col-4 text-end">{{ data.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td class="sticky-col col-5">{{ data.parcelasLabel }}</td>
<td *ngFor="let m of data.months" class="text-end">
{{ m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #emptyState>
<div class="empty-state">Sem dados para o ano selecionado.</div>
</ng-template>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="close.emit()">Fechar</button>
</div>
</div>
</div>

View File

@ -0,0 +1,202 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16);
}
.lg-backdrop {
position: fixed;
inset: 0;
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.2), rgba(0, 0, 0, 0.56) 42%);
z-index: 9990;
backdrop-filter: blur(5px);
}
.lg-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9995;
padding: 16px;
}
.lg-modal-card {
background: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 20px;
box-shadow: 0 30px 62px -16px rgba(0, 0, 0, 0.42);
width: min(1200px, 98vw);
overflow: hidden;
animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.1), rgba(255, 255, 255, 0.95) 72%);
}
.modal-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 900;
.icon-bg {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(3, 15, 170, 0.1);
color: var(--blue);
}
}
.modal-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
select {
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
padding: 6px 10px;
font-weight: 800;
background: rgba(255, 255, 255, 0.92);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--brand);
box-shadow: var(--focus-ring);
}
}
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.86);
color: rgba(17, 18, 20, 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: rgba(227, 61, 207, 0.26);
background: #fff;
color: var(--brand);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.modal-body {
padding: 16px;
background: linear-gradient(180deg, rgba(248, 249, 251, 0.98), rgba(255, 255, 255, 0.98));
}
.annual-table {
overflow-x: auto;
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 12px;
background: #fff;
}
.annual-table table {
border-collapse: collapse;
min-width: 1100px;
width: 100%;
font-size: 12px;
}
.annual-table th,
.annual-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
white-space: nowrap;
}
.annual-table thead th {
background: #f8f9fb;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 10px;
color: rgba(17, 18, 20, 0.6);
}
.sticky-col {
position: sticky;
left: 0;
background: #fff;
z-index: 2;
box-shadow: 2px 0 0 rgba(17, 18, 20, 0.04);
}
.col-1 { left: 0; min-width: 180px; }
.col-2 { left: 180px; min-width: 140px; }
.col-3 { left: 320px; min-width: 120px; }
.col-4 { left: 440px; min-width: 120px; text-align: right; }
.col-5 { left: 560px; min-width: 80px; }
.modal-footer {
padding: 14px 20px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
justify-content: flex-end;
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96));
}
.btn-primary {
height: 38px;
border-radius: 10px;
border: 1px solid #030faa;
font-weight: 700;
font-size: 12px;
cursor: pointer;
padding: 0 14px;
background: linear-gradient(135deg, #1543ff, #030faa);
color: #fff;
box-shadow: 0 10px 20px rgba(3, 15, 170, 0.24);
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(3, 15, 170, 0.28);
filter: brightness(1.04);
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.empty-state {
text-align: center;
padding: 24px;
font-weight: 700;
color: rgba(17, 18, 20, 0.6);
}
@keyframes popUp {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}

View File

@ -0,0 +1,41 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export type AnnualMonthValue = {
month: number;
label: string;
value: number | null;
};
export type AnnualRow = {
cliente: string;
linha: string;
item: string;
total: number;
parcelasLabel: string;
months: AnnualMonthValue[];
};
@Component({
selector: 'app-parcelamento-detalhamento-anual-modal',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './parcelamento-detalhamento-anual-modal.html',
styleUrls: ['./parcelamento-detalhamento-anual-modal.scss'],
})
export class ParcelamentoDetalhamentoAnualModalComponent {
@Input() open = false;
@Input() years: number[] = [];
@Input() selectedYear: number | null = null;
@Input() data: AnnualRow | null = null;
@Output() close = new EventEmitter<void>();
@Output() yearChange = new EventEmitter<number>();
onYearChange(value: unknown): void {
const year = Number(value);
if (!Number.isFinite(year)) return;
this.yearChange.emit(year);
}
}

View File

@ -0,0 +1,74 @@
<div class="filters-card" role="region" aria-label="Filtros de parcelamentos">
<div class="filters-head">
<div class="filters-title-wrap">
<div class="filters-title">
<i class="bi bi-funnel"></i>
<span>Filtros da listagem</span>
</div>
<small>Use os campos abaixo para refinar a consulta sem alterar os dados.</small>
</div>
<div class="filters-actions">
<button class="btn-primary" type="button" (click)="apply.emit()" [disabled]="loading">
<i class="bi bi-check2-circle"></i>
Aplicar filtros
</button>
<button class="btn-ghost" type="button" (click)="clear.emit()" [disabled]="loading">
<i class="bi bi-eraser"></i>
Limpar
</button>
</div>
</div>
<div class="filters-grid">
<div class="filter-field">
<label>AnoRef</label>
<input type="number" placeholder="Ano" [(ngModel)]="filters.anoRef" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Linha</label>
<input type="text" placeholder="Ex: 11999999999" [(ngModel)]="filters.linha" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Cliente</label>
<input type="text" placeholder="Nome do cliente" [(ngModel)]="filters.cliente" [disabled]="loading" />
</div>
<div class="filter-field">
<label>Competencia</label>
<div class="competencia-row">
<input type="number" placeholder="Ano" [(ngModel)]="filters.competenciaAno" [disabled]="loading" />
<app-select
class="select-glass"
size="sm"
[options]="monthOptions"
labelKey="label"
valueKey="value"
placeholder="Mes"
[(ngModel)]="filters.competenciaMes"
[disabled]="loading"
></app-select>
</div>
<small class="hint warn" *ngIf="competenciaInvalid">Informe ano e mes.</small>
</div>
</div>
<div class="filters-meta">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[(ngModel)]="filters.search"
(ngModelChange)="searchChange.emit(filters.search)" />
</div>
<div class="filter-chips" *ngIf="activeChips && activeChips.length">
<span class="chip" *ngFor="let chip of activeChips">
<strong>{{ chip.label }}:</strong> {{ chip.value }}
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,300 @@
:host {
display: block;
min-width: 0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
.filters-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
padding: 16px;
display: grid;
gap: 14px;
background: rgba(255, 255, 255, 0.95);
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
overflow: hidden;
}
.filters-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters-head > * {
min-width: 0;
}
.filters-title-wrap {
display: grid;
gap: 6px;
flex: 1 1 360px;
min-width: 0;
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.filters-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--pg-text, #0f172a);
font-weight: 800;
i {
color: var(--pg-primary, #1f4fd6);
}
}
.filters-actions {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.btn-primary,
.btn-ghost {
height: 38px;
border-radius: var(--pg-radius-sm, 10px);
border: 1px solid transparent;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-primary {
color: #fff;
border-color: var(--pg-primary-strong, #153caa);
background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa));
box-shadow: 0 10px 20px rgba(31, 79, 214, 0.25);
}
.btn-ghost {
color: var(--pg-text, #0f172a);
background: #fff;
border-color: var(--pg-border-strong, #c8d4e4);
}
.btn-primary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--pg-primary, #1f4fd6);
color: var(--pg-primary-strong, #153caa);
}
.filters-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--pg-text-soft, #64748b);
}
input {
width: 100%;
height: 40px;
border-radius: var(--pg-radius-sm, 10px);
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
padding: 0 12px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--pg-primary, #1f4fd6);
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
background: #f5f8fd;
color: var(--pg-text-soft, #64748b);
cursor: not-allowed;
}
}
}
.competencia-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.competencia-row > * {
min-width: 0;
width: 100%;
}
.filters-meta {
border-top: 1px dashed var(--pg-border, #dbe3ef);
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.search-box {
display: inline-flex;
align-items: center;
gap: 8px;
width: min(420px, 100%);
min-width: 0;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: var(--pg-radius-sm, 10px);
background: #fff;
padding: 0 12px;
height: 40px;
i {
color: var(--pg-text-soft, #64748b);
}
input {
width: 100%;
border: none;
outline: none;
font-size: 13px;
color: var(--pg-text, #0f172a);
background: transparent;
}
&:focus-within {
border-color: var(--pg-primary, #1f4fd6);
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.filter-chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid rgba(31, 79, 214, 0.2);
background: rgba(31, 79, 214, 0.1);
padding: 4px 10px;
color: var(--pg-text-muted, #475569);
font-size: 11px;
font-weight: 700;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
strong {
color: var(--pg-primary-strong, #153caa);
}
}
.hint {
font-size: 12px;
color: var(--pg-text-muted, #475569);
}
.hint.warn {
color: var(--pg-warning, #b4690e);
font-weight: 700;
}
.select-glass {
display: block;
width: 100%;
min-width: 0;
background: #fff;
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: var(--pg-radius-sm, 10px);
color: var(--pg-text, #0f172a);
font-weight: 700;
}
@media (max-width: 1100px) {
.filters-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.filters-actions {
width: 100%;
}
.filters-actions .btn-primary,
.filters-actions .btn-ghost {
flex: 1;
}
.filters-grid {
grid-template-columns: 1fr;
}
.filters-meta {
align-items: stretch;
}
.search-box {
width: 100%;
min-width: 0;
}
.filter-chips {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,36 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../../../components/custom-select/custom-select';
export type MonthOption = { value: number; label: string };
export type ParcelamentosFiltersModel = {
anoRef: string;
linha: string;
cliente: string;
competenciaAno: string;
competenciaMes: number | '';
search: string;
};
export type FilterChip = { label: string; value: string };
@Component({
selector: 'app-parcelamentos-filters',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './parcelamentos-filters.html',
styleUrls: ['./parcelamentos-filters.scss'],
})
export class ParcelamentosFiltersComponent {
@Input() filters!: ParcelamentosFiltersModel;
@Input() monthOptions: MonthOption[] = [];
@Input() loading = false;
@Input() competenciaInvalid = false;
@Input() activeChips: FilterChip[] = [];
@Output() apply = new EventEmitter<void>();
@Output() clear = new EventEmitter<void>();
@Output() searchChange = new EventEmitter<string>();
}

View File

@ -0,0 +1,14 @@
<div class="parcelamentos-kpis" *ngIf="cards?.length">
<div class="kpi-card" *ngFor="let k of cards">
<span class="kpi-label">{{ k?.label }}</span>
<span
class="kpi-value"
[class.tone-brand]="k?.tone === 'brand'"
[class.tone-success]="k?.tone === 'success'"
[class.tone-danger]="k?.tone === 'danger'"
[class.tone-info]="k?.tone === 'info'">
{{ k?.value }}
</span>
<span class="kpi-hint" *ngIf="k?.hint">{{ k?.hint }}</span>
</div>
</div>

View File

@ -0,0 +1,57 @@
:host {
display: block;
min-width: 0;
}
.parcelamentos-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(196px, 1fr));
gap: 12px;
}
.kpi-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
background: linear-gradient(180deg, #ffffff, #f8fbff);
padding: 14px 15px;
display: grid;
gap: 6px;
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
}
.kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
color: var(--pg-text-soft, #64748b);
}
.kpi-value {
font-size: 1.2rem;
line-height: 1.2;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
.kpi-hint {
font-size: 12px;
font-weight: 600;
color: var(--pg-text-muted, #475569);
}
.tone-brand {
color: var(--pg-primary, #1f4fd6);
}
.tone-success {
color: #1c7a3e;
}
.tone-danger {
color: #b42323;
}
.tone-info {
color: #1f4fd6;
}

View File

@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ParcelamentoKpi = {
label: string;
value: string;
hint?: string;
tone?: 'brand' | 'success' | 'danger' | 'info' | 'muted';
};
@Component({
selector: 'app-parcelamentos-kpis',
standalone: true,
imports: [CommonModule],
templateUrl: './parcelamentos-kpis.html',
styleUrls: ['./parcelamentos-kpis.scss'],
})
export class ParcelamentosKpisComponent {
@Input() cards: ParcelamentoKpi[] = [];
}

View File

@ -0,0 +1,161 @@
<div class="table-card">
<div class="table-head">
<div class="table-head-left">
<h3>Carteira de Parcelamentos</h3>
<small>Visualizacao paginada com filtros e acoes por registro</small>
</div>
<div class="segmented" role="tablist" aria-label="Segmentos de parcelamentos">
<button
type="button"
class="segment-btn"
*ngFor="let s of segments"
[class.active]="segment === s.key"
(click)="segmentChange.emit(s.key)">
{{ s.label }}
<span class="count">{{ segmentCounts[s.key] || 0 }}</span>
</button>
</div>
</div>
<div class="table-state loading" *ngIf="loading">
<div class="state-icon"><i class="bi bi-hourglass-split"></i></div>
<div class="state-copy">
<strong>Carregando parcelamentos...</strong>
<span>Aguarde enquanto os dados sao atualizados.</span>
</div>
<div class="skeleton-group">
<div class="skeleton-row" *ngFor="let _ of skeletonRows">
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
<span class="skeleton-line"></span>
</div>
</div>
</div>
<div class="table-state error" *ngIf="!loading && errorMessage">
<div class="state-icon"><i class="bi bi-exclamation-triangle"></i></div>
<div class="state-copy">
<strong>Falha ao carregar dados</strong>
<span>{{ errorMessage }}</span>
</div>
</div>
<div class="table-state empty" *ngIf="!loading && !errorMessage && items.length === 0">
<div class="state-icon"><i class="bi bi-inbox"></i></div>
<div class="state-copy">
<strong>Nenhum parcelamento encontrado</strong>
<span>Altere os filtros para tentar novamente.</span>
</div>
</div>
<div class="parcelamentos-table-wrap" *ngIf="!loading && !errorMessage && items.length">
<table class="table-modern">
<thead>
<tr>
<th class="col-ano nowrap">Ano ref.</th>
<th class="col-linha nowrap">Linha</th>
<th class="col-cliente">Cliente</th>
<th class="col-status nowrap">Status</th>
<th class="col-parcela nowrap">Parcela atual</th>
<th class="col-valor text-end nowrap">Valor cheio</th>
<th class="col-valor text-end nowrap">Desconto</th>
<th class="col-valor text-end nowrap">Valor c/ desconto</th>
<th class="col-acoes text-center nowrap">Acoes</th>
</tr>
</thead>
<tbody>
<tr class="table-row" *ngFor="let row of items; trackBy: trackById">
<td class="text-muted fw-bold col-ano nowrap">{{ row.anoRef ?? '-' }}</td>
<td class="text-blue fw-black col-linha nowrap">{{ row.linha || '-' }}</td>
<td class="col-cliente">{{ row.cliente || '-' }}</td>
<td class="col-status nowrap">
<span class="status-pill" [class]="'status-pill status-' + row.status">
{{ row.statusLabel }}
</span>
</td>
<td class="col-parcela nowrap">{{ row.progressLabel || '-' }}</td>
<td class="col-valor text-end money-strong nowrap">
{{ row.valorCheioNumber === null || row.valorCheioNumber === undefined
? '-' : (row.valorCheioNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-valor text-end text-danger nowrap">
{{ row.descontoNumber === null || row.descontoNumber === undefined
? '-' : (row.descontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-valor text-end money-strong nowrap">
{{ row.valorComDescontoNumber === null || row.valorComDescontoNumber === undefined
? '-' : (row.valorComDescontoNumber | currency:'BRL':'symbol':'1.2-2':'pt-BR') }}
</td>
<td class="col-acoes text-center nowrap">
<div class="action-group">
<button
class="btn-icon"
type="button"
title="Detalhes"
aria-label="Detalhes"
(click)="detail.emit(row)">
<i class="bi bi-eye"></i>
</button>
<button
class="btn-icon ghost"
type="button"
title="Editar"
aria-label="Editar"
(click)="edit.emit(row)">
<i class="bi bi-pencil"></i>
</button>
<button
class="btn-icon danger"
type="button"
title="Excluir"
aria-label="Excluir"
*ngIf="isAdmin"
(click)="remove.emit(row)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="page-info">
Mostrando {{ pageStart }}-{{ pageEnd }} de {{ total }}
</div>
<div class="pagination" role="navigation" aria-label="Paginacao da tabela">
<button class="btn-ghost icon-only" type="button" (click)="pageChange.emit(page - 1)" [disabled]="page <= 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="btn-page"
type="button"
*ngFor="let p of pageNumbers"
[class.active]="p === page"
(click)="pageChange.emit(p)">
{{ p }}
</button>
<button class="btn-ghost icon-only" type="button" (click)="pageChange.emit(page + 1)" [disabled]="page >= (pageNumbers[pageNumbers.length - 1] || page)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="page-size">
<span>Itens por pag</span>
<select
class="select-glass"
[ngModel]="pageSize"
(ngModelChange)="pageSizeChange.emit($event)">
<option *ngFor="let size of pageSizeOptions" [value]="size">{{ size }}</option>
</select>
</div>
</div>
</div>

View File

@ -0,0 +1,499 @@
:host {
display: block;
min-width: 0;
}
.table-card {
border: 1px solid var(--pg-border, #dbe3ef);
border-radius: var(--pg-radius-md, 14px);
overflow: hidden;
background: #fff;
box-shadow: var(--pg-shadow-sm, 0 8px 18px rgba(15, 23, 42, 0.08));
}
.table-head {
padding: 14px 16px;
border-bottom: 1px solid var(--pg-border, #dbe3ef);
background: linear-gradient(180deg, #f8fbff, #ffffff 75%);
display: grid;
gap: 12px;
}
.table-head-left {
display: grid;
gap: 4px;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 800;
color: var(--pg-text, #0f172a);
}
small {
color: var(--pg-text-soft, #64748b);
font-size: 12px;
font-weight: 600;
}
}
.segmented {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.segment-btn {
border: 1px solid var(--pg-border-strong, #c8d4e4);
border-radius: 999px;
background: #fff;
color: var(--pg-text-muted, #475569);
padding: 7px 12px;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 7px;
cursor: pointer;
transition: all 0.2s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.segment-btn:hover {
border-color: var(--pg-primary, #1f4fd6);
color: var(--pg-primary-strong, #153caa);
}
.segment-btn.active {
background: rgba(31, 79, 214, 0.12);
border-color: rgba(31, 79, 214, 0.3);
color: var(--pg-primary-strong, #153caa);
}
.segment-btn .count {
border-radius: 999px;
padding: 2px 7px;
font-size: 11px;
font-weight: 800;
background: rgba(15, 23, 42, 0.08);
color: var(--pg-text-muted, #475569);
}
.table-state {
padding: 24px 16px;
display: grid;
gap: 12px;
justify-items: center;
text-align: center;
}
.state-icon {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid var(--pg-border, #dbe3ef);
background: var(--pg-surface-alt, #f8fafc);
color: var(--pg-text-muted, #475569);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
}
.state-copy {
display: grid;
gap: 4px;
strong {
color: var(--pg-text, #0f172a);
font-size: 0.92rem;
font-weight: 800;
}
span {
color: var(--pg-text-soft, #64748b);
font-size: 0.82rem;
font-weight: 600;
}
}
.table-state.error .state-icon {
color: var(--pg-danger, #c52929);
border-color: rgba(197, 41, 41, 0.32);
background: rgba(197, 41, 41, 0.08);
}
.table-state.empty .state-icon {
color: var(--pg-warning, #b4690e);
border-color: rgba(180, 105, 14, 0.32);
background: rgba(180, 105, 14, 0.1);
}
.skeleton-group {
width: min(760px, 100%);
display: grid;
gap: 8px;
}
.skeleton-row {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr 1fr;
}
.skeleton-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, #e6edf8, #dbe6f3, #e6edf8);
background-size: 240px 100%;
animation: shimmer 1.3s infinite linear;
}
.parcelamentos-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.table-modern {
width: max-content;
min-width: 1120px;
border-collapse: separate;
border-spacing: 0;
thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 11px 10px;
border-bottom: 1px solid var(--pg-border-strong, #c8d4e4);
background: #f5f9ff;
color: var(--pg-text-soft, #64748b);
font-size: 0.67rem;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 800;
white-space: nowrap;
text-align: left;
}
tbody tr {
transition: background 0.18s ease;
}
tbody tr:nth-child(even) {
background: #f9fbff;
}
tbody tr:hover {
background: rgba(31, 79, 214, 0.08);
}
td {
padding: 11px 10px;
border-bottom: 1px solid #e9eef6;
font-size: 0.84rem;
color: var(--pg-text, #0f172a);
white-space: nowrap;
text-align: left;
vertical-align: middle;
}
}
.table-row {
cursor: default;
}
.nowrap {
white-space: nowrap;
}
.col-ano {
width: 90px;
}
.col-linha {
width: 138px;
}
.col-cliente {
width: 260px;
max-width: 260px;
}
.col-status {
width: 110px;
}
.col-parcela {
width: 130px;
}
.col-valor {
width: 150px;
text-align: right;
}
.col-acoes {
width: 152px;
min-width: 152px;
text-align: center;
}
.table-modern thead .col-ano,
.table-modern thead .col-linha,
.table-modern thead .col-cliente,
.table-modern thead .col-status,
.table-modern thead .col-parcela,
.table-modern thead .col-acoes,
.table-modern tbody .col-ano,
.table-modern tbody .col-linha,
.table-modern tbody .col-cliente,
.table-modern tbody .col-status,
.table-modern tbody .col-parcela,
.table-modern tbody .col-acoes {
text-align: center;
}
.text-end {
text-align: right;
}
.text-center {
text-align: center;
}
.text-blue {
color: var(--pg-primary-strong, #153caa);
}
.text-danger {
color: var(--pg-danger, #c52929);
}
.text-muted {
color: var(--pg-text-muted, #475569);
}
.fw-bold {
font-weight: 700;
}
.fw-black {
font-weight: 800;
}
.money-strong {
color: var(--pg-primary, #1f4fd6);
font-weight: 800;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid #d5dfef;
background: #f2f6fc;
color: #334155;
font-size: 11px;
font-weight: 800;
}
.status-ativos {
background: rgba(28, 122, 62, 0.14);
color: #1c7a3e;
border-color: rgba(28, 122, 62, 0.28);
}
.status-futuros {
background: rgba(31, 79, 214, 0.12);
color: #1f4fd6;
border-color: rgba(31, 79, 214, 0.28);
}
.status-finalizados {
background: rgba(197, 41, 41, 0.12);
color: #b42323;
border-color: rgba(197, 41, 41, 0.25);
}
.action-group {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: nowrap;
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid #cfd9e9;
border-radius: 10px;
background: #eff4ff;
color: var(--pg-primary-strong, #153caa);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--pg-primary, #1f4fd6);
background: #e3edff;
}
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
}
.btn-icon.ghost {
background: #f4f6fb;
color: #475569;
}
.btn-icon.danger {
background: rgba(197, 41, 41, 0.12);
color: #b42323;
border-color: rgba(197, 41, 41, 0.22);
}
.table-footer {
border-top: 1px solid var(--pg-border, #dbe3ef);
background: #fff;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page-info {
color: var(--pg-text-muted, #475569);
font-size: 12px;
font-weight: 700;
}
.pagination {
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-ghost.icon-only,
.btn-page {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22));
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.btn-page.active {
color: #fff;
border-color: var(--pg-primary-strong, #153caa);
background: linear-gradient(140deg, var(--pg-primary, #1f4fd6), var(--pg-primary-strong, #153caa));
}
.page-size {
display: inline-flex;
align-items: center;
gap: 8px;
span {
color: var(--pg-text-soft, #64748b);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 800;
}
}
.select-glass {
min-width: 76px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--pg-border-strong, #c8d4e4);
background: #fff;
color: var(--pg-text, #0f172a);
font-weight: 700;
padding: 0 8px;
}
@keyframes shimmer {
0% {
background-position: -120px 0;
}
100% {
background-position: 120px 0;
}
}
@media (max-width: 1180px) {
.table-modern {
min-width: 1020px;
}
.col-cliente {
width: 210px;
max-width: 210px;
}
}
@media (max-width: 760px) {
.table-head,
.table-footer {
padding-left: 12px;
padding-right: 12px;
}
.segmented {
width: 100%;
}
.segment-btn {
flex: 1;
justify-content: space-between;
min-width: 0;
}
.table-footer {
justify-content: center;
}
.page-size {
width: 100%;
justify-content: center;
}
}

View File

@ -0,0 +1,66 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ParcelamentoListItem } from '../../../../services/parcelamentos.service';
export type ParcelamentoSegment = 'todos' | 'ativos' | 'futuros' | 'finalizados';
export type ParcelamentoViewItem = ParcelamentoListItem & {
status: 'ativos' | 'futuros' | 'finalizados';
statusLabel: string;
progressLabel: string;
valorParcela?: number | null;
valorCheioNumber?: number | null;
descontoNumber?: number | null;
valorComDescontoNumber?: number | null;
};
@Component({
selector: 'app-parcelamentos-table',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './parcelamentos-table.html',
styleUrls: ['./parcelamentos-table.scss'],
})
export class ParcelamentosTableComponent {
@Input() items: ParcelamentoViewItem[] = [];
@Input() loading = false;
@Input() errorMessage = '';
@Input() isAdmin = false;
@Input() segment: ParcelamentoSegment = 'todos';
@Input() segmentCounts: Record<ParcelamentoSegment, number> = {
todos: 0,
ativos: 0,
futuros: 0,
finalizados: 0,
};
@Input() page = 1;
@Input() pageNumbers: number[] = [];
@Input() pageStart = 0;
@Input() pageEnd = 0;
@Input() total = 0;
@Input() pageSize = 10;
@Input() pageSizeOptions: number[] = [];
@Output() segmentChange = new EventEmitter<ParcelamentoSegment>();
@Output() detail = new EventEmitter<ParcelamentoViewItem>();
@Output() edit = new EventEmitter<ParcelamentoViewItem>();
@Output() remove = new EventEmitter<ParcelamentoViewItem>();
@Output() pageChange = new EventEmitter<number>();
@Output() pageSizeChange = new EventEmitter<number>();
readonly segments: Array<{ key: ParcelamentoSegment; label: string }> = [
{ key: 'todos', label: 'Lista geral' },
{ key: 'ativos', label: 'Ativos' },
{ key: 'futuros', label: 'Futuros' },
{ key: 'finalizados', label: 'Finalizados' },
];
skeletonRows = Array.from({ length: 6 });
trackById(_: number, item: ParcelamentoViewItem): string {
return item.id;
}
}

View File

@ -0,0 +1,251 @@
<section class="parcelamentos-page">
<div class="container-geral-responsive">
<div class="parcelamentos-shell">
<header class="page-header">
<div class="page-header-main">
<div class="title-group">
<span class="title-badge"><i class="bi bi-wallet2"></i> PARCELAMENTOS</span>
<div class="header-title">
<h2>Gestao de Parcelamentos</h2>
<p>Painel administrativo de parcelas de aparelhos e contratos</p>
</div>
</div>
<div class="header-actions">
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-repeat"></i> Atualizar
</button>
<button class="btn-primary" type="button" (click)="openCreateModal()">
<i class="bi bi-plus-circle"></i> Novo Parcelamento
</button>
</div>
</div>
<div class="header-highlights" aria-label="Resumo da listagem">
<div class="highlight-card">
<span>Total de registros</span>
<strong>{{ total }}</strong>
</div>
<div class="highlight-card">
<span>Pagina atual</span>
<strong>{{ page }} de {{ totalPages }}</strong>
</div>
<div class="highlight-card">
<span>Segmento ativo</span>
<strong>
{{ activeSegment === 'todos' ? 'Lista geral' : (activeSegment === 'ativos' ? 'Ativos' : (activeSegment === 'futuros' ? 'Futuros' : 'Finalizados')) }}
</strong>
</div>
</div>
</header>
<app-parcelamentos-kpis [cards]="kpiCards"></app-parcelamentos-kpis>
<app-parcelamentos-filters
[filters]="filters"
[monthOptions]="monthOptions"
[loading]="loading"
[competenciaInvalid]="competenciaInvalid"
[activeChips]="activeChips"
(apply)="applyFilters()"
(clear)="clearFilters()"
(searchChange)="onSearchChange($event)">
</app-parcelamentos-filters>
<app-parcelamentos-table
[items]="viewItems"
[loading]="loading"
[errorMessage]="errorMessage"
[segment]="activeSegment"
[segmentCounts]="segmentCounts"
[page]="page"
[pageNumbers]="pageNumbers"
[pageStart]="pageStart"
[pageEnd]="pageEnd"
[total]="total"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[isAdmin]="isAdmin"
(segmentChange)="setSegment($event)"
(detail)="openDetails($event)"
(edit)="openEdit($event)"
(remove)="openDelete($event)"
(pageChange)="goToPage($event)"
(pageSizeChange)="onPageSizeChange($event)">
</app-parcelamentos-table>
</div>
</div>
</section>
<!-- Modal detalhes -->
<div class="lg-backdrop" *ngIf="detailOpen" (click)="closeDetails()"></div>
<div class="lg-modal" *ngIf="detailOpen">
<div class="lg-modal-card parcelamento-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg"><i class="bi bi-card-list"></i></span>
<span>Detalhes do Parcelamento</span>
</div>
<div class="modal-actions">
<button class="btn-icon" type="button" (click)="closeDetails()" aria-label="Fechar modal">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="modal-body">
<div class="detail-state" *ngIf="detailLoading && !selectedDetail">
<div class="spinner-border text-brand" role="status"></div>
<span>Carregando detalhes...</span>
</div>
<div class="detail-state error" *ngIf="!detailLoading && detailError && !selectedDetail">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ detailError }}</span>
</div>
<ng-container *ngIf="selectedDetail as detail">
<div class="detail-grid">
<div class="detail-card">
<small>Cliente</small>
<span class="detail-strong">{{ detail.cliente || '-' }}</span>
</div>
<div class="detail-card">
<small>Linha</small>
<span class="detail-strong text-blue">{{ detail.linha || '-' }}</span>
</div>
<div class="detail-card">
<small>AnoRef</small>
<span>{{ detail.anoRef ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Item</small>
<span>{{ detail.item ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Qt Parcelas</small>
<span>{{ displayQtParcelas(detail) }}</span>
</div>
<div class="detail-card">
<small>Parcela Atual</small>
<span class="detail-strong">{{ detail.parcelaAtual ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Total Parcelas</small>
<span>{{ detail.totalParcelas ?? '-' }}</span>
</div>
<div class="detail-card">
<small>Status</small>
<span class="status-pill">{{ detailStatus }}</span>
</div>
<div class="detail-card">
<small>Valor Cheio</small>
<span>{{ formatMoney(detail.valorCheio) }}</span>
</div>
<div class="detail-card">
<small>Desconto</small>
<span class="text-danger">{{ formatMoney(detail.desconto) }}</span>
</div>
<div class="detail-card highlight">
<small>Valor com Desconto</small>
<span class="detail-strong money-strong">{{ formatMoney(detail.valorComDesconto) }}</span>
</div>
</div>
<div class="annual-section">
<div class="annual-head">
<div class="section-title">
<i class="bi bi-table"></i>
<span>Detalhamento anual</span>
</div>
</div>
<div class="annual-table-shell" *ngIf="annualRows.length > 0; else annualEmpty">
<table class="table-modern annual-table">
<thead>
<tr>
<th class="sticky-col col-1">Ano</th>
<th class="sticky-col col-2 text-end">Total</th>
<th *ngFor="let m of annualMonthHeaders" class="text-end">{{ m.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of annualRows">
<td class="sticky-col col-1">{{ row.year }}</td>
<td class="sticky-col col-2 text-end">{{ row.total | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}</td>
<td *ngFor="let m of row.months" class="text-end">
{{ m.value !== null && m.value !== undefined ? (m.value | currency:'BRL':'symbol':'1.2-2':'pt-BR') : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<ng-template #annualEmpty>
<div class="annual-empty">
Sem dados anuais.
</div>
</ng-template>
</div>
</ng-container>
</div>
<div class="modal-footer">
<button class="btn-primary" type="button" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>
<app-parcelamento-create-modal
[open]="createOpen"
[model]="createModel"
[monthOptions]="monthOptions"
[loading]="createSaving"
[errorMessage]="createError"
title="Novo Parcelamento"
submitLabel="Salvar"
(close)="closeCreateModal()"
(save)="saveNewParcelamento($event)">
</app-parcelamento-create-modal>
<app-parcelamento-create-modal
*ngIf="editOpen && editModel"
[open]="editOpen"
[model]="editModel"
[monthOptions]="monthOptions"
[loading]="editSaving"
[errorMessage]="editError"
title="Editar Parcelamento"
submitLabel="Atualizar"
(close)="closeEditModal()"
(save)="saveEditParcelamento($event)">
</app-parcelamento-create-modal>
<!-- Delete modal -->
<div class="lg-backdrop" *ngIf="deleteOpen"></div>
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-compact" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Parcelamento
</div>
<button class="btn-icon" type="button" (click)="cancelDelete()" aria-label="Fechar modal de exclusao">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o parcelamento <strong>{{ deleteTarget?.linha }}</strong>?</p>
<small class="text-danger" *ngIf="deleteError">{{ deleteError }}</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-ghost" type="button" (click)="cancelDelete()">Cancelar</button>
<button class="btn-danger" type="button" [disabled]="deleteLoading" (click)="confirmDelete()">
{{ deleteLoading ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,614 @@
:host {
--pg-font-sans: 'IBM Plex Sans', 'Source Sans 3', 'Manrope', 'Segoe UI', sans-serif;
--pg-primary: #1f4fd6;
--pg-primary-strong: #153caa;
--pg-primary-soft: rgba(31, 79, 214, 0.12);
--pg-primary-soft-2: rgba(31, 79, 214, 0.18);
--pg-text: #0f172a;
--pg-text-muted: #475569;
--pg-text-soft: #64748b;
--pg-bg: #f3f6fb;
--pg-surface: #ffffff;
--pg-surface-alt: #f8fafc;
--pg-border: #dbe3ef;
--pg-border-strong: #c8d4e4;
--pg-warning: #b4690e;
--pg-warning-soft: rgba(180, 105, 14, 0.14);
--pg-danger: #c52929;
--pg-danger-soft: rgba(197, 41, 41, 0.12);
--pg-success: #1c7a3e;
--pg-radius-sm: 10px;
--pg-radius-md: 14px;
--pg-radius-lg: 18px;
--pg-shadow-sm: 0 8px 18px rgba(15, 23, 42, 0.08);
--pg-shadow-md: 0 16px 32px rgba(15, 23, 42, 0.12);
--pg-shadow-lg: 0 24px 56px rgba(15, 23, 42, 0.25);
--pg-focus-ring: 0 0 0 3px rgba(31, 79, 214, 0.22);
--brand: var(--pg-primary);
--blue: var(--pg-primary-strong);
--text: var(--pg-text);
--muted: var(--pg-text-muted);
--focus-ring: var(--pg-focus-ring);
display: block;
color: var(--pg-text);
font-family: var(--pg-font-sans);
}
.parcelamentos-page {
min-height: 100vh;
padding: 0 12px 72px;
display: flex;
justify-content: center;
position: relative;
background:
radial-gradient(1100px 450px at 10% -10%, rgba(31, 79, 214, 0.11), transparent 60%),
radial-gradient(900px 420px at 100% 0%, rgba(30, 64, 175, 0.07), transparent 58%),
linear-gradient(180deg, #f9fbff 0%, var(--pg-bg) 75%);
}
.container-geral-responsive {
width: 100%;
max-width: 1280px;
position: relative;
z-index: 1;
margin-top: 28px;
}
.parcelamentos-shell {
display: grid;
gap: 16px;
min-width: 0;
}
.parcelamentos-shell > * {
min-width: 0;
}
.page-header {
display: grid;
gap: 14px;
border: 1px solid var(--pg-border);
border-radius: var(--pg-radius-lg);
padding: 18px 20px;
background: linear-gradient(165deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.94));
box-shadow: var(--pg-shadow-sm);
}
.page-header-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title-group {
display: grid;
gap: 8px;
}
.title-badge {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 6px 11px;
border-radius: 999px;
background: var(--pg-primary-soft);
border: 1px solid var(--pg-primary-soft-2);
color: var(--pg-primary-strong);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
i {
color: var(--pg-primary);
}
}
.header-title h2 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.65rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.header-title p {
margin: 4px 0 0;
font-size: 0.92rem;
color: var(--pg-text-muted);
font-weight: 600;
}
.header-actions {
display: inline-flex;
gap: 10px;
flex-wrap: wrap;
}
.header-highlights {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.highlight-card {
border: 1px solid var(--pg-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.84);
padding: 10px 12px;
display: grid;
gap: 4px;
span {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--pg-text-soft);
font-weight: 700;
}
strong {
font-size: 0.95rem;
color: var(--pg-text);
font-weight: 800;
}
}
.btn-primary,
.btn-ghost,
.btn-danger {
height: 40px;
border-radius: var(--pg-radius-sm);
border: 1px solid transparent;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease;
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring);
}
&:disabled {
opacity: 0.62;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-primary {
color: #fff;
background: linear-gradient(140deg, var(--pg-primary), var(--pg-primary-strong));
border-color: var(--pg-primary-strong);
box-shadow: 0 10px 22px rgba(31, 79, 214, 0.28);
}
.btn-ghost {
color: var(--pg-text);
background: #fff;
border-color: var(--pg-border-strong);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
}
.btn-danger {
color: #fff;
background: linear-gradient(145deg, #cf3131, #a91f1f);
border-color: #a91f1f;
box-shadow: 0 10px 20px rgba(169, 31, 31, 0.24);
}
.btn-primary:hover,
.btn-ghost:hover,
.btn-danger:hover {
transform: translateY(-1px);
}
.btn-ghost:hover {
border-color: var(--pg-primary);
color: var(--pg-primary-strong);
}
.lg-backdrop {
position: fixed;
inset: 0;
background:
radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%),
rgba(15, 23, 42, 0.6);
z-index: 9990;
backdrop-filter: blur(4px);
}
.lg-modal {
position: fixed;
inset: 0;
z-index: 9995;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.lg-modal-card {
width: min(1180px, 98vw);
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--pg-surface);
border: 1px solid var(--pg-border);
border-radius: 18px;
box-shadow: var(--pg-shadow-lg);
animation: pop-up 0.24s ease;
}
.modal-compact {
width: min(560px, 96vw);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--pg-border);
background: linear-gradient(180deg, rgba(244, 248, 255, 0.96), rgba(255, 255, 255, 0.96));
}
.modal-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 800;
color: var(--pg-text);
}
.icon-bg {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--pg-primary-soft);
color: var(--pg-primary-strong);
}
.icon-bg.danger-soft {
background: var(--pg-danger-soft);
color: var(--pg-danger);
}
.modal-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-icon {
width: 34px;
height: 34px;
border: 1px solid var(--pg-border-strong);
border-radius: 10px;
background: #fff;
color: var(--pg-text-soft);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--pg-primary-strong);
border-color: var(--pg-primary);
transform: translateY(-1px);
}
&:focus-visible {
outline: none;
box-shadow: var(--pg-focus-ring);
}
}
.modal-body {
padding: 18px;
background: linear-gradient(180deg, #f8fbff, #ffffff 80%);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.modal-footer {
padding: 14px 18px;
border-top: 1px solid var(--pg-border);
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
background: #fff;
}
.detail-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 180px;
color: var(--pg-text-muted);
font-weight: 700;
}
.detail-state.error {
color: var(--pg-danger);
}
.text-brand {
color: var(--pg-primary);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.detail-card {
border: 1px solid var(--pg-border);
border-radius: 12px;
padding: 11px 12px;
background: #fff;
display: grid;
gap: 6px;
color: var(--pg-text-muted);
small {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.66rem;
font-weight: 800;
}
span {
color: var(--pg-text);
font-size: 0.88rem;
font-weight: 700;
}
}
.detail-card.highlight {
grid-column: span 2;
background: linear-gradient(180deg, #ffffff, #f4f8ff);
}
.detail-strong {
font-weight: 800;
}
.text-blue {
color: var(--pg-primary-strong);
}
.money-strong {
color: var(--pg-primary);
}
.text-danger {
color: var(--pg-danger);
}
.status-pill {
width: fit-content;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--pg-primary-soft-2);
background: var(--pg-primary-soft);
color: var(--pg-primary-strong);
font-size: 0.74rem;
font-weight: 800;
}
.annual-section {
margin-top: 16px;
border: 1px solid var(--pg-border);
border-radius: 14px;
padding: 12px;
background: #fff;
display: grid;
gap: 10px;
}
.annual-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.section-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 800;
color: var(--pg-text);
}
.annual-table-shell {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--pg-border);
border-radius: 12px;
}
.annual-table {
width: max-content;
min-width: 1220px;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
th,
td {
border-bottom: 1px solid #e7edf6;
padding: 8px 10px;
white-space: nowrap;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--pg-text-soft);
background: #f6f9fe;
font-size: 10px;
font-weight: 800;
}
}
.annual-section .sticky-col {
position: sticky;
left: 0;
z-index: 3;
background: #fff;
box-shadow: 2px 0 0 #eef3fb;
}
.annual-section .col-1 {
left: 0;
min-width: 90px;
}
.annual-section .col-2 {
left: 90px;
min-width: 128px;
}
.annual-empty {
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--pg-border-strong);
border-radius: 10px;
font-size: 0.84rem;
font-weight: 700;
color: var(--pg-text-muted);
background: var(--pg-surface-alt);
}
.confirm-delete {
min-height: 150px;
display: grid;
align-content: center;
justify-items: center;
gap: 10px;
text-align: center;
color: var(--pg-text-muted);
p {
margin: 0;
color: var(--pg-text);
font-weight: 600;
}
}
.confirm-icon {
width: 54px;
height: 54px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--pg-danger);
border: 1px solid rgba(197, 41, 41, 0.22);
background: rgba(197, 41, 41, 0.1);
font-size: 1.15rem;
}
@keyframes pop-up {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 1100px) {
.header-highlights {
grid-template-columns: 1fr;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.container-geral-responsive {
margin-top: 18px;
}
.page-header,
.modal-body,
.modal-header,
.modal-footer {
padding-left: 14px;
padding-right: 14px;
}
.header-actions {
width: 100%;
}
.header-actions .btn-primary,
.header-actions .btn-ghost {
flex: 1;
}
.detail-grid {
grid-template-columns: 1fr;
}
.detail-card.highlight {
grid-column: span 1;
}
.modal-footer {
justify-content: stretch;
}
.modal-footer .btn-primary,
.modal-footer .btn-ghost,
.modal-footer .btn-danger {
flex: 1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
<section class="perfil-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>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-person-circle"></i> PERFIL
</div>
<div class="header-title">
<h5 class="title mb-0">MEU PERFIL</h5>
<small class="subtitle">Atualize seus dados e credenciais de acesso</small>
</div>
<div class="header-actions"></div>
</div>
</div>
<div class="geral-body">
<div class="perfil-sections">
<div class="perfil-section-card">
<div class="section-header">
<h2>Informação de perfil</h2>
</div>
<div class="form-alert error" *ngIf="profileError">
{{ profileError }}
</div>
<div class="form-alert success" *ngIf="profileSuccess">
{{ profileSuccess }}
</div>
<form class="profile-form" [formGroup]="profileForm" (ngSubmit)="onSaveProfile()" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="profileNome">Nome</label>
<input
id="profileNome"
type="text"
class="form-control"
formControlName="nome"
/>
<small class="field-error" *ngIf="hasProfileFieldError('nome', 'required')">
Nome é obrigatório.
</small>
<small class="field-error" *ngIf="hasProfileFieldError('nome', 'minlength')">
Nome deve ter pelo menos 2 caracteres.
</small>
</div>
<div class="form-field">
<label for="profileEmail">Email</label>
<input
id="profileEmail"
type="email"
class="form-control"
formControlName="email"
/>
<small class="field-error" *ngIf="hasProfileFieldError('email', 'required')">
Email é obrigatório.
</small>
<small class="field-error" *ngIf="hasProfileFieldError('email', 'email')">
Email inválido.
</small>
</div>
</div>
<div class="section-actions">
<button
type="submit"
class="btn btn-brand btn-sm"
[disabled]="loadingProfile || savingProfile || profileForm.invalid"
>
<span class="spinner-border spinner-border-sm me-2" *ngIf="savingProfile"></span>
{{ savingProfile ? 'SALVANDO...' : 'SALVAR' }}
</button>
</div>
</form>
</div>
<div class="perfil-section-card">
<div class="section-header">
<h2>Atualizar senha</h2>
</div>
<div class="form-alert error" *ngIf="passwordError">
{{ passwordError }}
</div>
<div class="form-alert success" *ngIf="passwordSuccess">
{{ passwordSuccess }}
</div>
<form class="profile-form" [formGroup]="passwordForm" (ngSubmit)="onChangePassword()" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="currentPassword">Credencial atual</label>
<input
id="currentPassword"
type="password"
class="form-control"
formControlName="credencialAtual"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('credencialAtual', 'required')">
Credencial atual é obrigatória.
</small>
</div>
<div class="form-field">
<label for="newPassword">Nova credencial</label>
<input
id="newPassword"
type="password"
class="form-control"
formControlName="novaCredencial"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('novaCredencial', 'required')">
Nova credencial é obrigatória.
</small>
<small class="field-error" *ngIf="hasPasswordFieldError('novaCredencial', 'minlength')">
Nova credencial deve ter pelo menos 8 caracteres.
</small>
</div>
<div class="form-field">
<label for="confirmNewPassword">Confirmar sua credencial nova</label>
<input
id="confirmNewPassword"
type="password"
class="form-control"
formControlName="confirmarNovaCredencial"
/>
<small class="field-error" *ngIf="hasPasswordFieldError('confirmarNovaCredencial', 'required')">
Confirmação da nova credencial é obrigatória.
</small>
<small class="field-error" *ngIf="passwordMismatch">
A nova credencial e a confirmação precisam ser iguais.
</small>
</div>
</div>
<div class="section-actions">
<button type="submit" class="btn btn-brand btn-sm" [disabled]="savingPassword || passwordForm.invalid">
<span class="spinner-border spinner-border-sm me-2" *ngIf="savingPassword"></span>
{{ savingPassword ? 'SALVANDO...' : 'SALVAR' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,295 @@
:host {
--brand: #E33DCF;
--blue: #030FAA;
--text: #111214;
--muted: rgba(17, 18, 20, 0.65);
--success-bg: rgba(25, 135, 84, 0.1);
--success-text: #198754;
--danger-bg: rgba(220, 53, 69, 0.1);
--danger-text: #dc3545;
--radius-xl: 22px;
--shadow-card: 0 22px 46px rgba(17, 18, 20, 0.1);
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: 1px solid rgba(227, 61, 207, 0.16);
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
.perfil-page {
min-height: 100vh;
padding: 0 12px;
display: flex;
justify-content: center;
position: relative;
overflow-y: auto;
background:
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
}
.page-blob {
position: fixed;
pointer-events: none;
border-radius: 999px;
filter: blur(34px);
opacity: 0.55;
z-index: 0;
background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.55), rgba(227, 61, 207, 0.06));
animation: floaty 10s ease-in-out infinite;
&.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; }
&.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; }
&.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; }
&.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: 0.45; }
}
@keyframes floaty {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18px, 10px) scale(1.03); }
100% { transform: translate(0, 0) scale(1); }
}
.container-geral-responsive {
width: 100%;
max-width: 1180px;
position: relative;
z-index: 1;
margin-top: 40px;
margin-bottom: 120px;
}
.geral-card {
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--glass-bg);
border: var(--glass-border);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
min-height: 80vh;
}
.geral-header {
padding: 16px 24px;
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2));
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(227, 61, 207, 0.22);
backdrop-filter: blur(10px);
color: var(--text);
font-size: 13px;
font-weight: 800;
i { color: var(--brand); }
}
.header-title {
justify-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.title {
font-size: 26px;
font-weight: 950;
letter-spacing: -0.3px;
color: var(--text);
margin-top: 10px;
}
.subtitle {
color: var(--muted);
font-weight: 700;
}
.header-actions {
justify-self: end;
min-height: 1px;
}
.geral-body {
padding: 18px;
}
.perfil-sections {
display: grid;
gap: 14px;
}
.perfil-section-card {
background: #ffffff;
border: 1px solid rgba(17, 18, 20, 0.08);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(17, 18, 20, 0.06);
padding: 18px 20px;
}
.section-header {
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 1rem;
font-weight: 900;
color: var(--text);
letter-spacing: 0.02em;
text-transform: uppercase;
}
}
.form-alert {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
&.error {
background: var(--danger-bg);
border: 1px solid rgba(220, 53, 69, 0.2);
color: var(--danger-text);
}
&.success {
background: var(--success-bg);
border: 1px solid rgba(25, 135, 84, 0.2);
color: var(--success-text);
}
}
.profile-form {
display: grid;
gap: 12px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
label {
font-size: 0.78rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
}
.form-control {
height: 40px;
border-radius: 10px;
border: 1px solid rgba(17, 18, 20, 0.16);
background: rgba(255, 255, 255, 0.86);
color: var(--text);
font-size: 14px;
padding: 0 12px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
&:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
background: #fff;
}
}
.field-error {
color: var(--danger-text);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.section-actions {
display: flex;
justify-content: flex-end;
}
.btn-brand {
background-color: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 900;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25);
filter: brightness(1.05);
}
&:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none;
}
}
@media (max-width: 768px) {
.container-geral-responsive {
margin-top: 16px;
margin-bottom: 64px;
}
.header-row-top {
grid-template-columns: 1fr;
text-align: center;
gap: 10px;
}
.title-badge {
justify-self: center;
}
.header-actions {
display: none;
}
.geral-header {
padding: 14px 16px;
}
.geral-body {
padding: 12px;
}
.perfil-section-card {
padding: 14px;
}
.section-actions {
justify-content: stretch;
}
.section-actions .btn {
width: 100%;
}
}

View File

@ -0,0 +1,229 @@
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 { ProfileService } from '../../services/profile.service';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-perfil',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './perfil.html',
styleUrls: ['./perfil.scss'],
})
export class Perfil implements OnInit {
profileForm: FormGroup;
passwordForm: FormGroup;
loadingProfile = false;
savingProfile = false;
savingPassword = false;
profileSuccess = '';
profileError = '';
passwordSuccess = '';
passwordError = '';
constructor(
private fb: FormBuilder,
private profileService: ProfileService,
private authService: AuthService
) {
this.profileForm = this.fb.group({
nome: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
});
this.passwordForm = this.fb.group(
{
credencialAtual: ['', [Validators.required]],
novaCredencial: ['', [Validators.required, Validators.minLength(8)]],
confirmarNovaCredencial: ['', [Validators.required]],
},
{ validators: this.passwordsMatchValidator }
);
}
ngOnInit(): void {
this.loadProfile();
}
onSaveProfile(): void {
if (this.savingProfile) return;
this.profileSuccess = '';
this.profileError = '';
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.savingProfile = true;
this.setProfileFormDisabled(true);
const payload = {
nome: String(this.profileForm.get('nome')?.value ?? '').trim(),
email: String(this.profileForm.get('email')?.value ?? '').trim(),
};
this.profileService.updateProfile(payload).subscribe({
next: (updated) => {
this.savingProfile = false;
this.setProfileFormDisabled(false);
this.profileSuccess = 'Perfil atualizado com sucesso.';
this.profileForm.patchValue({
nome: updated.nome ?? '',
email: updated.email ?? '',
});
this.profileForm.markAsPristine();
this.authService.updateUserProfile({
nome: updated.nome ?? '',
email: updated.email ?? '',
});
},
error: (err: HttpErrorResponse) => {
this.savingProfile = false;
this.setProfileFormDisabled(false);
this.profileError = this.extractErrorMessage(err, 'Não foi possível atualizar o perfil.');
},
});
}
onChangePassword(): void {
if (this.savingPassword) return;
this.passwordSuccess = '';
this.passwordError = '';
if (this.passwordForm.invalid) {
this.passwordForm.markAllAsTouched();
return;
}
this.savingPassword = true;
this.setPasswordFormDisabled(true);
const payload = {
credencialAtual: String(this.passwordForm.get('credencialAtual')?.value ?? ''),
novaCredencial: String(this.passwordForm.get('novaCredencial')?.value ?? ''),
confirmarNovaCredencial: String(this.passwordForm.get('confirmarNovaCredencial')?.value ?? ''),
};
this.profileService.changePassword(payload).subscribe({
next: () => {
this.savingPassword = false;
this.setPasswordFormDisabled(false);
this.passwordSuccess = 'Credencial atualizada com sucesso.';
this.passwordForm.reset();
},
error: (err: HttpErrorResponse) => {
this.savingPassword = false;
this.setPasswordFormDisabled(false);
this.passwordError = this.extractErrorMessage(err, 'Não foi possível atualizar a credencial.');
},
});
}
hasProfileFieldError(field: string, error?: string): boolean {
const control = this.profileForm.get(field);
if (!control) return false;
if (error) return !!(control.touched && control.hasError(error));
return !!(control.touched && control.invalid);
}
hasPasswordFieldError(field: string, error?: string): boolean {
const control = this.passwordForm.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.passwordForm.get('confirmarNovaCredencial')?.touched;
return !!(confirmTouched && this.passwordForm.errors?.['passwordMismatch']);
}
private loadProfile(): void {
this.loadingProfile = true;
this.profileSuccess = '';
this.profileError = '';
this.setProfileFormDisabled(true);
this.profileService.getMe().subscribe({
next: (me) => {
this.loadingProfile = false;
this.profileForm.patchValue({
nome: me.nome ?? '',
email: me.email ?? '',
});
this.setProfileFormDisabled(false);
},
error: (err: HttpErrorResponse) => {
this.loadingProfile = false;
this.setProfileFormDisabled(false);
if (err.status === 404) {
const authProfile = this.authService.currentUserProfile;
if (authProfile) {
this.profileForm.patchValue({
nome: authProfile.nome ?? '',
email: authProfile.email ?? '',
});
}
}
this.profileError = this.extractErrorMessage(err, 'Não foi possível carregar os dados do perfil.');
},
});
}
private setProfileFormDisabled(disabled: boolean): void {
if (disabled) {
this.profileForm.disable({ emitEvent: false });
return;
}
this.profileForm.enable({ emitEvent: false });
}
private setPasswordFormDisabled(disabled: boolean): void {
if (disabled) {
this.passwordForm.disable({ emitEvent: false });
return;
}
this.passwordForm.enable({ emitEvent: false });
}
private extractErrorMessage(err: HttpErrorResponse, fallback: string): string {
if (err.status === 404) {
return 'API de perfil não encontrada (404). Reinicie/atualize o back-end com os novos endpoints de perfil.';
}
const apiError = err?.error;
if (Array.isArray(apiError?.errors) && apiError.errors.length) {
const msg = apiError.errors[0]?.message;
if (msg) return String(msg);
}
if (typeof apiError?.message === 'string' && apiError.message.trim()) {
return apiError.message.trim();
}
if (typeof apiError === 'string' && apiError.trim()) {
return apiError.trim();
}
return fallback;
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const nova = group.get('novaCredencial')?.value;
const confirmar = group.get('confirmarNovaCredencial')?.value;
if (!nova || !confirmar) return null;
return nova === confirmar ? null : { passwordMismatch: true };
}
}

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Register } from './register';
@ -8,7 +11,12 @@ describe('Register', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Register]
imports: [Register],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();

View File

@ -78,7 +78,7 @@ export class Register {
this.isSubmitting = false;
// Se você não quer manter "logado" após cadastrar:
localStorage.removeItem('token');
this.authService.logout();
await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.');

View File

@ -1,275 +0,0 @@
<section class="relatorios-page">
<div class="wrap">
<div class="container">
<div class="page-head fade-in-up">
<div class="title">
<span class="badge">
<i class="bi bi-bar-chart-fill"></i> Relatórios
</span>
<p class="subtitle">Resumo e indicadores do ambiente.</p>
</div>
<div class="status" *ngIf="loading">
<i class="bi bi-arrow-repeat spin"></i>
<span>Carregando...</span>
</div>
<div class="status warn" *ngIf="!loading && errorMsg">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ errorMsg }}</span>
</div>
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card lift" *ngFor="let k of kpis; let i = index" [style.animationDelay.ms]="i * 40">
<div class="kpi-icon">
<i [class]="k.icon"></i>
</div>
<div class="kpi-content">
<div class="kpi-title">{{ k.title }}</div>
<div class="kpi-value">{{ k.value }}</div>
<div class="kpi-hint" *ngIf="k.hint">{{ k.hint }}</div>
</div>
</div>
</div>
<!-- Status das linhas -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-pie-chart-fill"></i>
Status das linhas
</div>
</div>
<div class="status-pie-grid">
<div class="pie-wrap">
<canvas #chartStatusPie></canvas>
</div>
<div class="status-metrics">
<div class="metric total">
<span class="dot d1"></span>
<div class="meta">
<div class="k">Total linhas</div>
<div class="v">{{ statusResumo.total | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d2"></span>
<div class="meta">
<div class="k">Ativas</div>
<div class="v">{{ statusResumo.ativos | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d3"></span>
<div class="meta">
<div class="k">Bloqueadas (perda/roubo)</div>
<div class="v">{{ statusResumo.perdaRoubo | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d4"></span>
<div class="meta">
<div class="k">Bloqueadas (120 dias)</div>
<div class="v">{{ statusResumo.bloq120 | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d5"></span>
<div class="meta">
<div class="k">Reservas</div>
<div class="v">{{ statusResumo.reservas | number:'1.0-0' }}</div>
</div>
</div>
<div class="metric">
<span class="dot d6"></span>
<div class="meta">
<div class="k">Bloqueadas (outros)</div>
<div class="v">{{ statusResumo.outras | number:'1.0-0' }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- ✅ NOVO POSICIONAMENTO: VIGÊNCIA abaixo de Status e acima dos gráficos 12 meses -->
<div class="charts-grid charts-vigencia">
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-calendar2-check"></i>
Contratos a encerrar (próximos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartVigenciaMesAno></canvas>
</div>
</div>
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-shield-exclamation"></i>
Vigência (supervisão)
</div>
</div>
<div class="chart-wrap">
<canvas #chartVigenciaSupervisao></canvas>
</div>
</div>
</div>
<!-- Charts (12 meses) -->
<div class="charts-grid">
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-arrow-repeat"></i>
MUREG (últimos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartMureg12></canvas>
</div>
</div>
<div class="cardx fade-in-up lift">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-shuffle"></i>
Troca de número (últimos 12 meses)
</div>
</div>
<div class="chart-wrap">
<canvas #chartTroca12></canvas>
</div>
</div>
</div>
<!-- Top Clientes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-trophy-fill"></i>
Top clientes (por linhas)
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>#</th>
<th>Cliente</th>
<th>Linhas</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!topClientes || topClientes.length === 0)">
<td colspan="3" class="muted">Nenhum dado encontrado.</td>
</tr>
<tr *ngFor="let c of topClientes; let i = index">
<td>{{ i + 1 }}</td>
<td class="cell-strong">{{ c.cliente }}</td>
<td>{{ c.linhas }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- MUREGs recentes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-clock-history"></i>
MUREGs recentes
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>Item</th>
<th>Linha antiga</th>
<th>Linha nova</th>
<th>ICCID</th>
<th>Cliente</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!muregsRecentes || muregsRecentes.length === 0)">
<td colspan="6" class="muted">Nenhum registro recente.</td>
</tr>
<tr *ngFor="let m of muregsRecentes">
<td>{{ m.item }}</td>
<td>{{ m.linhaAntiga || '-' }}</td>
<td class="cell-strong">{{ m.linhaNova || '-' }}</td>
<td>{{ m.iccid || '-' }}</td>
<td>{{ m.cliente || '-' }}</td>
<td>{{ m.dataDaMureg ? (m.dataDaMureg | date:'dd/MM/yyyy') : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Trocas recentes -->
<div class="cardx fade-in-up">
<div class="cardx-head">
<div class="cardx-title">
<i class="bi bi-clock-history"></i>
Trocas recentes
</div>
</div>
<div class="table-wrap">
<table class="tablex">
<thead>
<tr>
<th>Item</th>
<th>Linha antiga</th>
<th>Linha nova</th>
<th>ICCID</th>
<th>Motivo</th>
<th>Data</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!loading && (!trocasRecentes || trocasRecentes.length === 0)">
<td colspan="6" class="muted">Nenhum registro recente.</td>
</tr>
<tr *ngFor="let t of trocasRecentes">
<td>{{ t.item }}</td>
<td>{{ t.linhaAntiga || '-' }}</td>
<td class="cell-strong">{{ t.linhaNova || '-' }}</td>
<td>{{ t.iccid || '-' }}</td>
<td class="cell-clip" [title]="t.motivo || ''">{{ t.motivo || '-' }}</td>
<td>{{ t.dataTroca ? (t.dataTroca | date:'dd/MM/yyyy') : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- sem footer / sem foot-space -->
</div>
</div>
</section>

View File

@ -1,341 +0,0 @@
:host {
display: block;
width: 100%;
overflow-x: hidden;
}
/* ✅ remove footer nessa página */
:host ::ng-deep footer,
:host ::ng-deep .footer,
:host ::ng-deep .portal-footer,
:host ::ng-deep .app-footer {
display: none !important;
}
.relatorios-page {
width: 100%;
overflow-x: hidden;
}
/* ✅ SUBIR MAIS A PÁGINA (antes 44px) */
.wrap {
padding-top: 15px; /* ✅ mais perto do header */
padding-bottom: 16px;
overflow-x: hidden;
}
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 14px;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px; /* ✅ era 14px */
@media (max-width: 900px) {
flex-direction: column;
align-items: stretch;
}
}
.title { min-width: 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
font-weight: 900;
font-family: 'Poppins', sans-serif;
color: rgba(17, 18, 20, 0.9);
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
i {
color: var(--brand-primary);
font-size: 18px;
line-height: 1;
}
}
.subtitle {
margin: 8px 0 0; /* ✅ era 10px */
color: rgba(17, 18, 20, 0.62);
font-weight: 600;
font-size: 13px;
}
.status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
color: rgba(17, 18, 20, 0.78);
font-weight: 700;
font-size: 13px;
i { color: var(--brand-primary); }
&.warn i { color: #d97706; }
}
.spin { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in-up { animation: fadeUp 420ms ease-out both; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.lift {
transition: transform 180ms ease, box-shadow 180ms ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 22px 50px rgba(0,0,0,0.10);
}
}
/* KPIs */
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 10px 0 12px; /* ✅ era 14px 0 16px */
@media (max-width: 1000px) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 520px) { grid-template-columns: 1fr; }
}
.kpi-card {
display: flex;
gap: 12px;
align-items: center;
padding: 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
min-width: 0;
}
.kpi-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--brand-primary), #6a55ff);
color: #fff;
flex: 0 0 auto;
i { font-size: 18px; line-height: 1; }
}
.kpi-content { min-width: 0; }
.kpi-title {
font-weight: 900;
font-size: 12px;
color: rgba(17, 18, 20, 0.65);
}
.kpi-value {
font-weight: 900;
font-size: 18px;
color: rgba(17, 18, 20, 0.92);
margin-top: 2px;
}
.kpi-hint {
margin-top: 4px;
font-size: 12px;
color: rgba(17, 18, 20, 0.55);
font-weight: 700;
}
/* Cards */
.cardx {
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08);
min-width: 0;
margin-top: 10px; /* ✅ era 12px */
}
.cardx-head {
padding: 12px 14px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.55);
border-top-left-radius: 18px;
border-top-right-radius: 18px;
}
.cardx-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 900;
font-family: 'Poppins', sans-serif;
color: rgba(17, 18, 20, 0.86);
i { color: var(--brand-primary); }
}
/* Status grid */
.status-pie-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
padding: 12px;
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.pie-wrap {
position: relative;
height: 260px;
padding: 6px;
canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
}
.status-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-content: start;
@media (max-width: 520px) { grid-template-columns: 1fr; }
}
.metric {
display: flex;
gap: 10px;
align-items: center;
padding: 12px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.70);
border: 1px solid rgba(0,0,0,0.06);
}
/* se quiser tirar o rosa do total, troque aqui */
.metric.total .meta .v {
color: #ff2d95;
}
.dot {
width: 10px;
height: 10px;
border-radius: 99px;
flex: 0 0 auto;
}
/* ✅ DOTS COM CORES "PADRÃO DE DASHBOARD" */
.dot.d1 { background: #ff2d95; } /* total (mantém rosa no card de total, se você quiser) */
.dot.d2 { background: #2E7D32; } /* Ativos - verde */
.dot.d3 { background: #D32F2F; } /* Perda/Roubo - vermelho */
.dot.d4 { background: #F57C00; } /* 120 dias - laranja */
.dot.d5 { background: #1976D2; } /* Reservas - azul */
.dot.d6 { background: #607D8B; } /* Outros - cinza */
.meta .k {
font-weight: 900;
font-size: 12px;
color: rgba(17,18,20,0.65);
}
.meta .v {
font-weight: 900;
font-size: 18px;
color: rgba(17,18,20,0.92);
margin-top: 2px;
}
/* Charts */
.charts-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 10px; /* ✅ era 12px */
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.charts-vigencia {
margin-top: 10px; /* ✅ era 12px */
}
.chart-wrap {
position: relative;
height: 320px;
padding: 12px 12px 16px;
overflow: hidden;
canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
}
/* Table */
.table-wrap {
padding: 10px 12px 14px;
overflow-x: auto;
}
.tablex {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.tablex th,
.tablex td {
padding: 10px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-weight: 800;
color: rgba(17, 18, 20, 0.8);
text-align: left;
white-space: nowrap;
}
.tablex th {
color: rgba(17, 18, 20, 0.65);
font-size: 12px;
}
.muted {
color: rgba(17, 18, 20, 0.55);
font-weight: 800;
}
.cell-strong {
font-weight: 900;
color: rgba(17, 18, 20, 0.92);
}
.cell-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -1,485 +0,0 @@
import {
Component,
AfterViewInit,
OnInit,
OnDestroy,
ViewChild,
ElementRef,
Inject,
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { PLATFORM_ID } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import Chart from 'chart.js/auto';
type KpiCard = {
title: string;
value: string;
icon: string;
hint?: string;
};
type SerieMesDto = {
mes: string;
total: number;
};
type TopClienteDto = {
cliente: string;
linhas: number;
};
type MuregRecenteDto = {
id: string;
item: number;
linhaAntiga?: string | null;
linhaNova?: string | null;
iccid?: string | null;
dataDaMureg?: string | null;
cliente?: string | null;
mobileLineId: string;
};
type TrocaRecenteDto = {
id: string;
item: number;
linhaAntiga?: string | null;
linhaNova?: string | null;
iccid?: string | null;
dataTroca?: string | null;
motivo?: string | null;
};
type VigenciaBucketsDto = {
vencidos: number;
aVencer0a30: number;
aVencer31a60: number;
aVencer61a90: number;
acima90: number;
};
type DashboardKpisDto = {
totalLinhas: number;
clientesUnicos: number;
ativos: number;
bloqueados: number;
reservas: number;
bloqueadosPerdaRoubo: number;
bloqueados120Dias: number;
bloqueadosOutros: number;
totalMuregs: number;
muregsUltimos30Dias: number;
totalTrocas: number;
trocasUltimos30Dias: number;
totalVigenciaLinhas: number;
vigenciaVencidos: number;
vigenciaAVencer30: number;
userDataRegistros: number;
userDataComCpf: number;
userDataComEmail: number;
};
type RelatoriosDashboardDto = {
kpis: DashboardKpisDto;
topClientes: TopClienteDto[];
serieMuregUltimos12Meses: SerieMesDto[];
serieTrocaUltimos12Meses: SerieMesDto[];
muregsRecentes: MuregRecenteDto[];
trocasRecentes: TrocaRecenteDto[];
// ✅ vigência
serieVigenciaEncerramentosProx12Meses: SerieMesDto[];
vigenciaBuckets: VigenciaBucketsDto;
};
@Component({
selector: 'app-relatorios',
standalone: true,
imports: [CommonModule],
templateUrl: './relatorios.html',
styleUrls: ['./relatorios.scss'],
})
export class Relatorios implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('chartMureg12') chartMureg12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartTroca12') chartTroca12?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartStatusPie') chartStatusPie?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaMesAno') chartVigenciaMesAno?: ElementRef<HTMLCanvasElement>;
@ViewChild('chartVigenciaSupervisao') chartVigenciaSupervisao?: ElementRef<HTMLCanvasElement>;
loading = true;
errorMsg: string | null = null;
kpis: KpiCard[] = [];
muregLabels: string[] = [];
muregValues: number[] = [];
trocaLabels: string[] = [];
trocaValues: number[] = [];
vigenciaLabels: string[] = [];
vigenciaValues: number[] = [];
vigBuckets: VigenciaBucketsDto = {
vencidos: 0,
aVencer0a30: 0,
aVencer31a60: 0,
aVencer61a90: 0,
acima90: 0,
};
topClientes: TopClienteDto[] = [];
muregsRecentes: MuregRecenteDto[] = [];
trocasRecentes: TrocaRecenteDto[] = [];
statusResumo = {
total: 0,
ativos: 0,
perdaRoubo: 0,
bloq120: 0,
reservas: 0,
outras: 0,
};
private viewReady = false;
private dataReady = false;
private chartMureg?: Chart;
private chartTroca?: Chart;
private chartPie?: Chart;
private chartVigMesAno?: Chart;
private chartVigSuper?: Chart;
private readonly baseApi: string;
// ✅ Paletas "padrão de dashboard" (fácil de entender)
private readonly STATUS_COLORS = {
ativos: '#2E7D32', // verde
perdaRoubo: '#D32F2F', // vermelho
bloq120: '#F57C00', // laranja
reservas: '#1976D2', // azul
outros: '#607D8B', // cinza
};
private readonly VIG_COLORS = {
vencidos: '#D32F2F', // vermelho
d0a30: '#F57C00', // laranja
d31a60: '#FBC02D', // amarelo
d61a90: '#1976D2', // azul
acima90: '#2E7D32', // verde
};
constructor(
private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
ngOnInit(): void {
this.loadDashboard();
}
ngAfterViewInit(): void {
this.viewReady = true;
this.tryBuildCharts();
}
ngOnDestroy(): void {
this.destroyCharts();
}
private async loadDashboard() {
this.loading = true;
this.errorMsg = null;
this.dataReady = false;
try {
const dto = await this.fetchDashboardReal();
this.applyDto(dto);
this.dataReady = true;
this.loading = false;
this.tryBuildCharts();
} catch {
this.loading = false;
this.errorMsg =
'Falha ao carregar Relatórios. Verifique se a API está rodando e o endpoint /api/relatorios/dashboard está acessível.';
}
}
private async fetchDashboardReal(): Promise<RelatoriosDashboardDto> {
if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado para charts');
const url = `${this.baseApi}/relatorios/dashboard`;
return await firstValueFrom(this.http.get<RelatoriosDashboardDto>(url));
}
private applyDto(dto: RelatoriosDashboardDto) {
const k = dto.kpis;
this.kpis = [
{ title: 'Linhas', value: this.formatInt(k.totalLinhas), icon: 'bi bi-sim', hint: 'Total cadastradas' },
{ title: 'Clientes', value: this.formatInt(k.clientesUnicos), icon: 'bi bi-building', hint: 'Clientes únicos' },
{ title: 'Ativos', value: this.formatInt(k.ativos), icon: 'bi bi-check2-circle', hint: 'Linhas ativas' },
{ title: 'Bloqueados', value: this.formatInt(k.bloqueados), icon: 'bi bi-slash-circle', hint: 'Somatório de bloqueios' },
{ title: 'Reservas', value: this.formatInt(k.reservas), icon: 'bi bi-inboxes', hint: 'Linhas em reserva' },
{ title: 'MUREGs (30d)', value: this.formatInt(k.muregsUltimos30Dias), icon: 'bi bi-arrow-repeat', hint: 'Últimos 30 dias' },
{ title: 'Trocas (30d)', value: this.formatInt(k.trocasUltimos30Dias), icon: 'bi bi-shuffle', hint: 'Últimos 30 dias' },
{ title: 'Vencidos', value: this.formatInt(k.vigenciaVencidos), icon: 'bi bi-exclamation-triangle', hint: 'Vigência vencida' },
{ title: 'A vencer (30d)', value: this.formatInt(k.vigenciaAVencer30), icon: 'bi bi-calendar2-week', hint: 'Vigência a vencer' },
{ title: 'Registros', value: this.formatInt(k.userDataRegistros), icon: 'bi bi-person-vcard', hint: 'Cadastros de usuário' },
];
this.muregLabels = (dto.serieMuregUltimos12Meses || []).map(x => x.mes);
this.muregValues = (dto.serieMuregUltimos12Meses || []).map(x => x.total);
this.trocaLabels = (dto.serieTrocaUltimos12Meses || []).map(x => x.mes);
this.trocaValues = (dto.serieTrocaUltimos12Meses || []).map(x => x.total);
this.vigenciaLabels = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.mes);
this.vigenciaValues = (dto.serieVigenciaEncerramentosProx12Meses || []).map(x => x.total);
this.vigBuckets = dto.vigenciaBuckets || this.vigBuckets;
this.topClientes = dto.topClientes || [];
this.muregsRecentes = dto.muregsRecentes || [];
this.trocasRecentes = dto.trocasRecentes || [];
this.statusResumo = {
total: k.totalLinhas ?? 0,
ativos: k.ativos ?? 0,
perdaRoubo: k.bloqueadosPerdaRoubo ?? 0,
bloq120: k.bloqueados120Dias ?? 0,
reservas: k.reservas ?? 0,
outras: k.bloqueadosOutros ?? 0,
};
}
private tryBuildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
if (!this.viewReady || !this.dataReady) return;
requestAnimationFrame(() => {
const canvases = [
this.chartStatusPie?.nativeElement,
this.chartVigenciaMesAno?.nativeElement,
this.chartVigenciaSupervisao?.nativeElement,
this.chartMureg12?.nativeElement,
this.chartTroca12?.nativeElement,
].filter(Boolean) as HTMLCanvasElement[];
if (canvases.length === 0) return;
// evita render quando canvas ainda não mediu (bug comum que "some")
if (canvases.some(c => c.clientWidth === 0 || c.clientHeight === 0)) {
requestAnimationFrame(() => this.tryBuildCharts());
return;
}
this.buildCharts();
});
}
private buildCharts() {
if (!isPlatformBrowser(this.platformId)) return;
this.destroyCharts();
// ✅ Status das linhas (paleta padrão)
const cP = this.chartStatusPie?.nativeElement;
if (cP) {
this.chartPie = new Chart(cP, {
type: 'doughnut',
data: {
labels: [
'Ativos',
'Bloqueadas (perda/roubo)',
'Bloqueadas (120 dias)',
'Reservas',
'Bloqueadas (outros)'
],
datasets: [{
data: [
this.statusResumo.ativos,
this.statusResumo.perdaRoubo,
this.statusResumo.bloq120,
this.statusResumo.reservas,
this.statusResumo.outras,
],
borderWidth: 1,
backgroundColor: [
this.STATUS_COLORS.ativos,
this.STATUS_COLORS.perdaRoubo,
this.STATUS_COLORS.bloq120,
this.STATUS_COLORS.reservas,
this.STATUS_COLORS.outros,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ Contratos a encerrar (próximos 12 meses) - barra azul padrão
const cV1 = this.chartVigenciaMesAno?.nativeElement;
if (cV1) {
this.chartVigMesAno = new Chart(cV1, {
type: 'bar',
data: {
labels: this.vigenciaLabels,
datasets: [{
label: 'Encerramentos',
data: this.vigenciaValues,
borderWidth: 0,
backgroundColor: '#1976D2', // azul padrão
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Vigência (supervisão) - paleta por urgência
const cV2 = this.chartVigenciaSupervisao?.nativeElement;
if (cV2) {
this.chartVigSuper = new Chart(cV2, {
type: 'doughnut',
data: {
labels: ['Vencidos', '030 dias', '3160 dias', '6190 dias', '> 90 dias'],
datasets: [{
data: [
this.vigBuckets.vencidos,
this.vigBuckets.aVencer0a30,
this.vigBuckets.aVencer31a60,
this.vigBuckets.aVencer61a90,
this.vigBuckets.acima90,
],
borderWidth: 1,
backgroundColor: [
this.VIG_COLORS.vencidos,
this.VIG_COLORS.d0a30,
this.VIG_COLORS.d31a60,
this.VIG_COLORS.d61a90,
this.VIG_COLORS.acima90,
],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.label}: ${this.formatInt(Number(ctx.raw || 0))}` },
},
},
},
});
}
// ✅ MUREG 12 meses
const cM = this.chartMureg12?.nativeElement;
if (cM) {
this.chartMureg = new Chart(cM, {
type: 'bar',
data: {
labels: this.muregLabels,
datasets: [{
label: 'MUREG',
data: this.muregValues,
borderWidth: 0,
backgroundColor: '#6A1B9A', // roxo (bem comum em dashboards)
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
// ✅ Troca 12 meses
const cT = this.chartTroca12?.nativeElement;
if (cT) {
this.chartTroca = new Chart(cT, {
type: 'bar',
data: {
labels: this.trocaLabels,
datasets: [{
label: 'Troca',
data: this.trocaValues,
borderWidth: 0,
backgroundColor: '#00897B', // teal (bem comum)
borderRadius: 10,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false } },
x: { border: { display: false } },
},
},
});
}
}
private destroyCharts() {
try { this.chartMureg?.destroy(); } catch {}
try { this.chartTroca?.destroy(); } catch {}
try { this.chartPie?.destroy(); } catch {}
try { this.chartVigMesAno?.destroy(); } catch {}
try { this.chartVigSuper?.destroy(); } catch {}
this.chartMureg = undefined;
this.chartTroca = undefined;
this.chartPie = undefined;
this.chartVigMesAno = undefined;
this.chartVigSuper = undefined;
}
private formatInt(v: number) {
return (v || 0).toLocaleString('pt-BR');
}
}

View File

@ -0,0 +1,615 @@
<section class="resumo-page">
<div class="wrap">
<div class="resumo-container">
<div class="page-head" data-animate>
<div class="title-group">
<span class="badge-pill"><i class="bi bi-graph-up"></i> Dashboard</span>
<div class="flex-title">
<h2 class="page-title">Resumo Gerencial</h2>
<div class="status-wrapper">
<div class="status loading" *ngIf="loading">
<i class="bi bi-arrow-repeat spin"></i> Atualizando dados...
</div>
<div class="status error" *ngIf="!loading && errorMessage">
<i class="bi bi-exclamation-triangle"></i> {{ errorMessage }}
</div>
<div class="status success" *ngIf="!loading && !errorMessage">
<i class="bi bi-check-circle"></i> Atualizado
</div>
</div>
</div>
<p class="page-subtitle">Visão consolidada de performance, contratos e indicadores operacionais.</p>
</div>
<div class="header-actions">
<button class="btn-ghost" type="button" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise" [class.spin]="loading"></i>
<span>Atualizar</span>
</button>
</div>
</div>
<div class="tab-bar" data-animate>
<button
type="button"
class="tab-btn"
*ngFor="let tab of tabs"
[class.active]="activeTab === tab.key"
(click)="setTab(tab.key)">
<i [class]="tab.icon"></i>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="tab-panel" *ngIf="activeTab === 'planos'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Planos & Contratos</h3>
<p>{{ showFinancial ? 'Performance financeira agrupada por modalidade de plano.' : 'Distribuição e volume de linhas por modalidade de plano.' }}</p>
</div>
<div class="hero-kpis">
<div class="kpi-card kpi-card--total-lines">
<span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong>
</div>
<div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Valor Total</span>
<strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong>
</div>
<div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Contratos</span>
<strong class="kpi-val">{{ formatMoney(contratosTotals?.valorTotal) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid planos-charts" data-animate>
<div class="chart-card" *ngIf="showFinancial">
<div class="card-header-clean">
<h3>Top Planos (Valor)</h3>
<p>Os planos com maior representatividade financeira.</p>
</div>
<div class="chart-area">
<canvas #chartPlanos></canvas>
</div>
</div>
<div class="chart-card" [class.full-span]="!showFinancial">
<div class="card-header-clean">
<h3>Top Planos (Volume)</h3>
<p>Quantidade de linhas ativas por tipo de plano.</p>
</div>
<div class="chart-area">
<canvas #chartPlanosLinhas></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Macrophony - Planos</h4>
<span>Detalhamento granular dos planos e suas variações.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<div class="macrophony-block" [class.compact]="macrophonyCompact">
<div class="table-tools">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[value]="macrophonySearch"
(input)="onMacrophonySearch($any($event.target).value)" />
</div>
<div class="tools-right">
<label class="select-label">
Exibir
<select [value]="macrophonyPageSize" (change)="onMacrophonyPageSizeChange($any($event.target).value)">
<option *ngFor="let size of macrophonyPageOptions" [value]="size">{{ size }}</option>
</select>
</label>
<div class="divider-v"></div>
<button class="btn-icon-text" type="button" (click)="toggleMacrophonyCompact()">
<i class="bi" [class.bi-arrows-angle-expand]="macrophonyCompact" [class.bi-arrows-collapse]="!macrophonyCompact"></i>
<span class="hide-mobile">{{ macrophonyCompact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportMacrophonyCsv()">
<i class="bi bi-download"></i>
<span class="hide-mobile">Exportar</span>
</button>
</div>
</div>
<div class="macrophony-list">
<div class="empty-state" *ngIf="!loading && macrophonyView.length === 0">
<i class="bi bi-inbox"></i>
<p>Nenhum dado encontrado.</p>
</div>
<div class="macrophony-group" *ngFor="let group of macrophonyView; trackBy: trackByIndex">
<div class="macrophony-row" (click)="toggleMacrophonyGroup(group.key)">
<div class="row-trigger">
<button class="group-toggle" type="button">
<i class="bi" [class.bi-chevron-down]="isMacrophonyOpen(group.key)" [class.bi-chevron-right]="!isMacrophonyOpen(group.key)"></i>
</button>
</div>
<div class="group-main">
<div class="group-title">{{ group.plano }}</div>
<div class="group-meta">
<span class="badge-tag">GB {{ group.gbLabel }}</span>
<span class="badge-tag secondary">{{ formatNumber(group.totalLinhas) }} linhas</span>
</div>
</div>
<div class="group-metrics" *ngIf="showFinancial">
<div class="metric">
<span class="lbl">Valor Total</span>
<strong class="val-money">{{ formatMoney(group.valorTotal) }}</strong>
</div>
<div class="metric hide-mobile">
<span class="lbl">Média Un.</span>
<strong>{{ formatMoney(group.valorUnitMedio) }}</strong>
</div>
</div>
<div class="group-actions">
<button class="btn-mini" type="button" (click)="openMacrophonyDetail(group); $event.stopPropagation()">
Detalhes
</button>
</div>
</div>
<div class="macrophony-details" *ngIf="isMacrophonyOpen(group.key)">
<div class="table-wrap is-nested">
<table class="data-table" [class.compact]="macrophonyCompact">
<thead>
<tr>
<th>Plano / Variação</th>
<th>Franquia</th>
<th class="text-right" *ngIf="showFinancial">Valor Un.</th>
<th class="text-right">Linhas</th>
<th class="text-right" *ngIf="showFinancial">Total</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of group.rows; trackBy: trackByIndex">
<td>
<div class="cell-flex">
{{ row.planoContrato || '-' }}
<i *ngIf="isVivoTravel(row.vivoTravel)" class="bi bi-airplane-fill text-brand" title="Vivo Travel"></i>
</div>
</td>
<td>{{ formatGb(row.gb) }}</td>
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorIndividualComSvas) }}</td>
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
<td class="text-right num-font font-bold" *ngIf="showFinancial">{{ formatMoney(row.valorTotal) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="table-footer">
<div class="table-count">
Exibindo {{ macrophonyPageStart }}-{{ macrophonyPageEnd }} de {{ macrophonyFilteredGroups.length }} grupos
</div>
<div class="pagination">
<button class="page-btn" (click)="goToMacrophonyPage(macrophonyPage - 1)" [disabled]="macrophonyPage === 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="page-btn"
*ngFor="let p of macrophonyPageNumbers"
[class.active]="p === macrophonyPage"
(click)="goToMacrophonyPage(p)">{{ p }}</button>
<button class="page-btn" (click)="goToMacrophonyPage(macrophonyPage + 1)" [disabled]="macrophonyPage === macrophonyTotalPages">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<div class="macrophony-summary" *ngIf="planosTotals">
<div class="summary-item">
<span>Total de Linhas</span>
<strong>{{ formatNumber(planosTotals.totalLinhasTotal) }}</strong>
</div>
<div class="summary-item highlight" *ngIf="showFinancial">
<span>Valor Total Global</span>
<strong>{{ formatMoney(planosTotals.valorTotal) }}</strong>
</div>
</div>
</div>
</details>
<details class="section-card">
<summary>
<div class="summary-content">
<h4>Resumo de Contratos</h4>
<span>Visão consolidada por tipo de contrato vigente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupPlanoContrato, footer: 'contratos', file: 'plano-contrato' }"></ng-container>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'clientes'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Clientes & Performance</h3>
<p>{{ showFinancial ? 'Analise a rentabilidade e custos por cliente.' : 'Distribuição e volume de linhas por cliente.' }}</p>
</div>
<div class="hero-kpis">
<div class="kpi-card kpi-card--total-lines">
<span class="kpi-lbl">Total Linhas</span>
<strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong>
</div>
<div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Receita Line</span>
<strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong>
</div>
<div class="kpi-card highlight" *ngIf="showFinancial">
<span class="kpi-lbl">Lucro Total</span>
<strong class="kpi-val text-success">{{ formatMoney(clientesTotals?.lucro) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>{{ showFinancial ? 'Top Clientes (Lucratividade)' : 'Top Clientes (Qtd. Linhas)' }}</h3>
<p>{{ showFinancial ? 'Clientes ordenados pelo maior retorno financeiro.' : 'Clientes com maior volume de linhas.' }}</p>
</div>
<div class="chart-area">
<canvas #chartClientes></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento Vivo x Line Móvel</h4>
<span>Comparativo de custos, receitas e margem por cliente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupClientes, footer: 'clientes', file: 'clientes-vivo-line' }"></ng-container>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'totais'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Totais Line</h3>
<p>Consolidado entre Pessoa Física (PF) e Jurídica (PJ).</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">PF Linhas</span>
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PF','PESSOA FISICA'])?.qtdLinhas) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-lbl">PJ Linhas</span>
<strong class="kpi-val">{{ formatNumber(findLineTotal(['PJ','PESSOA JURIDICA'])?.qtdLinhas) }}</strong>
</div>
<div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Lucro Consolidado</span>
<strong class="kpi-val text-success">{{ formatMoney(totaisLineLucroConsolidado) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Distribuição PF vs PJ</h3>
<p>Proporção da base de linhas ativas.</p>
</div>
<div class="chart-area">
<canvas #chartTotais></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento Totais</h4>
<span>Tabela analítica dos totais processados.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupTotaisLine, footer: 'none', file: 'totais-line' }"></ng-container>
</details>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Distribuição por GB</h4>
<span>Tabela GB / QTD / SOMA importada da aba RESUMO.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<div class="grouped-block">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>GB</th>
<th class="text-right">QTD</th>
<th class="text-right">SOMA</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of gbDistribuicaoRows; trackBy: trackByIndex">
<td><span class="num-font">{{ formatGb(row.gb) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatNumber(row.qtd) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatMoney(row.soma) }}</span></td>
</tr>
<tr class="total-row" *ngIf="gbDistribuicaoRows.length">
<td>Total</td>
<td class="text-right"><span class="num-font">{{ formatNumber(gbDistribuicaoTotalLinhas) }}</span></td>
<td class="text-right"><span class="num-font">{{ formatMoney(gbDistribuicaoSomaTotal) }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
<div class="tab-panel" *ngIf="activeTab === 'reserva'">
<div class="section-hero" data-animate>
<div class="hero-content">
<div class="hero-text">
<h3>Estoque de Reserva</h3>
<p>Monitoramento de linhas disponíveis por DDD.</p>
</div>
<div class="hero-kpis">
<div class="kpi-card">
<span class="kpi-lbl">Linhas em Estoque</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.qtdLinhasTotal) }}</strong>
</div>
<div class="kpi-card" *ngIf="showFinancial">
<span class="kpi-lbl">Custo de Reserva</span>
<strong class="kpi-val">{{ formatNumber(reservaTotals?.total) }}</strong>
</div>
</div>
</div>
</div>
<div class="section-grid full-chart" data-animate>
<div class="chart-card">
<div class="card-header-clean">
<h3>Concentração por DDD</h3>
<p>Regiões com maior volume de linhas em reserva.</p>
</div>
<div class="chart-area">
<canvas #chartReserva></canvas>
</div>
</div>
</div>
<details class="section-card" open>
<summary>
<div class="summary-content">
<h4>Detalhamento por DDD</h4>
<span>Lista completa de estoque agrupada geograficamente.</span>
</div>
<div class="summary-icon"><i class="bi bi-chevron-down"></i></div>
</summary>
<ng-container *ngTemplateOutlet="groupedTableBlock; context: { group: groupReserva, footer: 'reserva', file: 'reserva-ddd' }"></ng-container>
</details>
</div>
</div>
</div>
<ng-template #groupedTableBlock let-group="group" let-footer="footer" let-file="file">
<div class="grouped-block" [class.compact]="group.compact">
<div class="table-tools">
<div class="search-box">
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Pesquisar..."
[value]="group.search"
(input)="onGroupedSearch(group, $any($event.target).value)" />
</div>
<div class="tools-right">
<button class="btn-icon-text" type="button" (click)="toggleGroupedCompact(group)">
<i class="bi" [class.bi-arrows-angle-expand]="group.compact" [class.bi-arrows-collapse]="!group.compact"></i>
<span class="hide-mobile">{{ group.compact ? 'Expandir' : 'Compactar' }}</span>
</button>
<button class="btn-icon-text" type="button" (click)="exportGroupedCsv(group, file)">
<i class="bi bi-download"></i>
<span class="hide-mobile">Exportar</span>
</button>
</div>
</div>
<div class="grouped-list">
<div class="empty-state" *ngIf="!loading && group.view.length === 0">
<p>Nenhum registro encontrado.</p>
</div>
<div class="grouped-group" *ngFor="let item of group.view; trackBy: trackByIndex">
<div class="grouped-row" (click)="toggleGroupedOpen(group, item.key)">
<div class="row-trigger">
<button class="group-toggle" type="button">
<i class="bi" [class.bi-chevron-down]="isGroupedOpen(group, item.key)" [class.bi-chevron-right]="!isGroupedOpen(group, item.key)"></i>
</button>
</div>
<div class="group-main">
<div class="group-title">{{ item.title }}</div>
<div class="group-subtitle" *ngIf="item.subtitle">{{ item.subtitle }}</div>
</div>
<div class="group-metrics">
<div class="metric" *ngFor="let metric of item.metrics">
<span class="lbl">{{ metric.label }}</span>
<strong class="num-font" [ngClass]="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<div class="group-actions">
<button class="btn-mini" type="button" (click)="openGroupedDetail(group, item); $event.stopPropagation()">
Ver Tabela
</button>
</div>
</div>
<div class="grouped-details" *ngIf="isGroupedOpen(group, item.key)">
<div class="table-wrap is-nested">
<table class="data-table" [class.compact]="group.compact">
<thead>
<tr>
<th
*ngFor="let col of group.table.columns"
[class.text-right]="col.align === 'right'"
[class.text-center]="col.align === 'center'">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of item.rows; trackBy: trackByIndex" [class.diff-row]="getTableRowClass(group.table, row)">
<td
*ngFor="let col of group.table.columns"
[class.text-right]="col.align === 'right'"
[class.text-center]="col.align === 'center'">
<span class="num-font" *ngIf="!col.badge" [ngClass]="col.tone ? getToneClass(col.value(row)) : null">
{{ formatCell(col, row) }}
</span>
<span *ngIf="col.badge" class="badge-tag">{{ formatCell(col, row) }}</span>
<i *ngIf="col.icon && col.icon(row)" [class]="col.icon(row)"></i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="grouped-summary-bar" *ngIf="showFinancial && footer === 'clientes' && clientesTotals">
<div class="sum-col">
<span class="lbl">Receita Line</span>
<strong>{{ formatMoney(clientesTotals.valorContratoLine) }}</strong>
</div>
<div class="sum-col">
<span class="lbl">Custo Vivo</span>
<strong>{{ formatMoney(clientesTotals.valorContratoVivo) }}</strong>
</div>
<div class="sum-col highlight">
<span class="lbl">Lucro Líquido</span>
<strong class="text-success">{{ formatMoney(clientesTotals.lucro) }}</strong>
</div>
</div>
<div class="table-footer">
<div class="table-count">Mostrando {{ getGroupedPageStart(group) }}-{{ getGroupedPageEnd(group) }} de {{ group.filtered.length }}</div>
<div class="pagination">
<button class="page-btn" (click)="goToGroupedPage(group, group.page - 1)" [disabled]="group.page === 1">
<i class="bi bi-chevron-left"></i>
</button>
<button
class="page-btn"
*ngFor="let p of getGroupedPageNumbers(group)"
[class.active]="p === group.page"
(click)="goToGroupedPage(group, p)">{{ p }}</button>
<button class="page-btn" (click)="goToGroupedPage(group, group.page + 1)" [disabled]="group.page === getGroupedTotalPages(group)">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
<div class="grouped-backdrop" *ngIf="group.detailOpen" (click)="closeGroupedDetail(group)"></div>
<div class="grouped-modal" *ngIf="group.detailOpen">
<div class="grouped-card" (click)="$event.stopPropagation()">
<div class="detail-head">
<h4>{{ group.detailGroup?.title }}</h4>
<button class="btn-icon" type="button" (click)="closeGroupedDetail(group)"><i class="bi bi-x-lg"></i></button>
</div>
<div class="grouped-modal-body">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th *ngFor="let col of group.table.columns" [class.text-right]="col.align === 'right'">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of group.detailGroup?.rows; trackBy: trackByIndex">
<td *ngFor="let col of group.table.columns" [class.text-right]="col.align === 'right'">
<span class="num-font">{{ formatCell(col, row) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</ng-template>
<div class="macrophony-backdrop" *ngIf="macrophonyDetailOpen" (click)="closeMacrophonyDetail()"></div>
<div class="macrophony-modal" *ngIf="macrophonyDetailOpen">
<div class="macrophony-card" (click)="$event.stopPropagation()">
<div class="detail-head">
<div>
<span class="detail-super">Detalhes do Plano</span>
<h4>{{ macrophonyDetailGroup?.plano }}</h4>
</div>
<button class="btn-icon" type="button" (click)="closeMacrophonyDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="macrophony-modal-body">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Variação</th>
<th>GB</th>
<th class="text-right" *ngIf="showFinancial">Valor Un.</th>
<th class="text-right">Total Linhas</th>
<th class="text-right" *ngIf="showFinancial">Valor Total</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of macrophonyDetailGroup?.rows; trackBy: trackByIndex">
<td>{{ row.planoContrato || '-' }}</td>
<td>{{ formatGb(row.gb) }}</td>
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorIndividualComSvas) }}</td>
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(row.valorTotal) }}</td>
</tr>
</tbody>
<tfoot *ngIf="macrophonyDetailGroup">
<tr class="total-row">
<td [attr.colspan]="showFinancial ? 3 : 2">Total deste grupo</td>
<td class="text-right num-font">{{ formatNumber(macrophonyDetailGroup.totalLinhas) }}</td>
<td class="text-right num-font" *ngIf="showFinancial">{{ formatMoney(macrophonyDetailGroup.valorTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,835 @@
:host {
/* Tokens de Cor - Enterprise Palette */
--brand: #e33dcf;
--brand-hover: #c92bb6;
--brand-soft: rgba(227, 61, 207, 0.08);
--brand-gradient: linear-gradient(135deg, #e33dcf 0%, #b0249d 100%);
--blue: #030faa;
--blue-soft: rgba(3, 15, 170, 0.06);
--text-main: #0f172a;
--text-sec: #64748b;
--text-light: #94a3b8;
--border: #e2e8f0;
--surface: #ffffff;
--bg-page: #f8fafc;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
/* Elevation & Depth */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02);
--shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-elevated: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
--shadow-modal: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.2);
display: block;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-main);
background: var(--bg-page);
}
.resumo-page {
min-height: 100vh;
padding-bottom: 80px;
}
.wrap { padding-top: 32px; }
.resumo-container {
width: 100%;
max-width: 1360px; /* Ligeiramente mais largo para dashboard */
margin: 0 auto;
padding: 0 24px;
@media (max-width: 768px) { padding: 0 16px; }
}
/* Animações */
[data-animate] {
opacity: 1;
transform: translateY(0);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
:host(.animate-ready) [data-animate] {
opacity: 0;
transform: translateY(15px);
}
:host(.animate-ready) [data-animate].is-visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 768px) {
.wrap {
padding-top: 12px;
}
:host(.animate-ready) [data-animate] {
transform: translateY(8px);
}
}
/* Header */
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
gap: 16px;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
.title-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
background: white;
border: 1px solid var(--border);
color: var(--brand);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
box-shadow: var(--shadow-sm);
width: fit-content;
}
.flex-title {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
margin: 0;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-main);
}
.page-subtitle {
margin: 0;
font-size: 14px;
color: var(--text-sec);
max-width: 600px;
}
/* Status Indicators */
.status-wrapper {
display: flex;
gap: 12px;
}
.status {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 99px;
&.loading { background: #eff6ff; color: var(--blue); }
&.error { background: #fef2f2; color: var(--danger); }
&.success { background: #f0fdf4; color: var(--success); }
}
.spin { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Botões */
.btn-ghost {
border: 1px solid var(--border);
background: white;
color: var(--text-main);
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
&:hover:not(:disabled) {
border-color: var(--brand);
color: var(--brand);
transform: translateY(-1px);
box-shadow: var(--shadow-card);
}
&:disabled { opacity: 0.6; cursor: wait; }
}
.btn-icon-text {
@extend .btn-ghost;
padding: 6px 12px;
font-size: 12px;
}
.btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(0,0,0,0.03);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(0,0,0,0.06);
color: var(--brand);
}
}
.btn-mini {
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
border: 1px solid var(--border);
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-sec);
&:hover {
border-color: var(--brand);
color: var(--brand);
}
}
/* Tabs */
.tab-bar {
display: flex;
gap: 6px;
padding: 4px;
border-radius: var(--radius-md);
background: white;
border: 1px solid var(--border);
margin-bottom: 24px;
width: fit-content;
box-shadow: var(--shadow-sm);
overflow-x: auto;
}
.tab-btn {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
color: var(--text-sec);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover { color: var(--text-main); background: #f1f5f9; }
&.active {
background: var(--brand-soft);
color: var(--brand);
font-weight: 700;
}
}
@media (max-width: 768px) {
.tab-bar {
width: 100%;
max-width: 100%;
min-width: 0;
flex-wrap: nowrap;
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
padding-bottom: 6px;
}
.tab-btn {
flex: 0 0 auto;
white-space: nowrap;
}
}
/* Hero Section */
.section-hero {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-card);
}
.hero-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
@media (max-width: 900px) {
flex-direction: column;
align-items: flex-start;
}
}
.hero-text h3 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 700;
}
.hero-text p {
margin: 0;
color: var(--text-sec);
font-size: 13px;
}
.hero-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
width: 100%;
max-width: 600px;
}
.kpi-card {
background: #f8fafc;
border: 1px solid var(--border);
padding: 12px 16px;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 4px;
transition: transform 0.2s;
&:hover {
border-color: #cbd5e1;
background: white;
box-shadow: var(--shadow-sm);
}
&.highlight {
background: linear-gradient(to bottom right, #f8fafc, #fff);
border-left: 3px solid var(--success);
}
}
.kpi-lbl {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-sec);
letter-spacing: 0.04em;
}
.kpi-val {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
font-feature-settings: "tnum";
}
.kpi-card.kpi-card--total-lines .kpi-val {
font-size: 16px;
}
/* Grids */
.section-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.planos-charts .chart-card {
grid-column: span 6;
@media (max-width: 960px) { grid-column: span 12; }
}
.planos-charts .chart-card.full-span {
grid-column: span 12;
}
.full-chart .chart-card {
grid-column: span 12;
}
.chart-card {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
height: 100%;
}
.chart-area {
position: relative;
height: 300px;
width: 100%;
margin-top: 16px;
}
.card-header-clean h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.card-header-clean p {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-sec);
}
/* Details/Summary Sections */
.section-card {
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
margin-bottom: 24px;
transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-elevated); }
}
summary {
list-style: none;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
background: white;
border-bottom: 1px solid transparent;
transition: background 0.2s;
&:hover { background: #fcfcfc; }
&::-webkit-details-marker { display: none; }
}
details[open] summary {
border-bottom-color: var(--border);
background: #f8fafc;
}
.summary-content h4 {
margin: 0 0 2px;
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.summary-content span {
font-size: 13px;
color: var(--text-sec);
}
.summary-icon {
color: var(--text-sec);
transition: transform 0.3s ease;
}
details[open] .summary-icon { transform: rotate(180deg); }
/* Tabelas e Listas */
.table-tools {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
background: white;
}
.search-box {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
padding: 8px 12px;
border-radius: 8px;
width: 300px;
max-width: 100%;
border: 1px solid transparent;
transition: all 0.2s;
&:focus-within {
background: white;
border-color: var(--brand);
box-shadow: 0 0 0 2px var(--brand-soft);
}
input {
border: none;
background: transparent;
width: 100%;
outline: none;
font-size: 13px;
}
i { color: var(--text-sec); }
}
.tools-right {
display: flex;
align-items: center;
gap: 12px;
}
.select-label {
font-size: 12px;
font-weight: 600;
color: var(--text-sec);
display: flex;
align-items: center;
gap: 8px;
select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 24px 4px 8px;
font-size: 12px;
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 6px center / 10px;
appearance: none;
cursor: pointer;
&:hover { border-color: var(--text-light); }
&:focus { outline: none; border-color: var(--brand); }
}
}
.divider-v {
width: 1px;
height: 24px;
background: var(--border);
}
/* Listas Agrupadas */
.macrophony-row, .grouped-row {
display: grid;
grid-template-columns: 40px minmax(200px, 1.5fr) 2fr auto;
gap: 16px;
padding: 16px 24px;
align-items: center;
border-bottom: 1px solid var(--border);
background: white;
transition: background 0.1s;
cursor: pointer;
&:hover { background: #f8fafc; }
@media (max-width: 768px) {
grid-template-columns: 40px 1fr;
gap: 8px;
}
}
.macrophony-row .group-actions,
.grouped-row .group-actions {
grid-column: -1;
justify-self: end;
}
.group-toggle {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
background: white;
color: var(--text-sec);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover { border-color: var(--brand); color: var(--brand); }
}
.group-title {
font-weight: 700;
font-size: 14px;
color: var(--text-main);
}
.group-meta {
display: flex;
gap: 6px;
margin-top: 4px;
}
.group-metrics {
display: flex;
justify-content: flex-end;
gap: 24px;
@media (max-width: 768px) {
grid-column: 2;
justify-content: flex-start;
margin-top: 8px;
}
}
.metric {
display: flex;
flex-direction: column;
align-items: flex-end;
@media (max-width: 768px) { align-items: flex-start; }
}
.metric .lbl {
font-size: 10px;
text-transform: uppercase;
font-weight: 700;
color: var(--text-light);
letter-spacing: 0.05em;
}
.metric strong {
font-size: 14px;
color: var(--text-main);
}
.badge-tag {
background: #f1f5f9;
color: var(--text-main);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
&.secondary { background: white; border-color: var(--border); color: var(--text-sec); }
}
/* Tabelas Internas */
.macrophony-details, .grouped-details {
background: #f8fafc;
border-bottom: 1px solid var(--border);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.table-wrap {
overflow-x: auto;
&.is-nested { padding: 16px 24px; }
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
&.compact td, &.compact th { padding: 8px 12px; }
th {
text-align: center;
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
color: var(--text-sec);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(248, 250, 252, 0.95);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
td {
text-align: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-main);
transition: background 0.1s;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: white; }
/* Auxiliares */
.text-right { text-align: center; }
.text-center { text-align: center; }
.num-font { font-family: 'Roboto Mono', monospace; font-size: 12px; }
.font-bold { font-weight: 700; }
.text-brand { color: var(--brand); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
}
.diff-row td { background: #fff5f9; }
/* Footers */
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: white;
border-top: 1px solid var(--border);
@media (max-width: 600px) {
flex-direction: column;
gap: 12px;
}
}
.table-count {
font-size: 12px;
color: var(--text-sec);
}
.pagination {
display: flex;
gap: 4px;
}
.page-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid var(--border);
background: white;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
&:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
&.active { background: var(--brand); color: white; border-color: var(--brand); }
&:disabled { opacity: 0.5; cursor: default; }
}
/* Resumo Bars (Totais) */
.macrophony-summary, .grouped-summary-bar {
display: flex;
gap: 24px;
padding: 16px 24px;
background: #f8fafc;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.summary-item, .sum-col {
display: flex;
flex-direction: column;
span { font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; }
strong { font-size: 16px; color: var(--text-main); }
&.highlight strong { color: var(--brand); }
}
/* Modais */
.macrophony-modal, .grouped-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.macrophony-backdrop, .grouped-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
z-index: 90;
}
.macrophony-card, .grouped-card {
background: white;
width: 100%;
max-width: 800px;
max-height: 85vh;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
z-index: 101;
overflow: hidden;
animation: modalUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalUp {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.detail-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
background: white;
h4 { margin: 0; font-size: 18px; font-weight: 700; }
.detail-super { display: block; font-size: 11px; text-transform: uppercase; color: var(--text-sec); font-weight: 700; margin-bottom: 4px; }
}
.macrophony-modal-body, .grouped-modal-body {
overflow-y: auto;
padding: 0;
}
/* Utils */
.hide-mobile {
@media (max-width: 600px) { display: none; }
}
.empty-state {
padding: 40px;
text-align: center;
color: var(--text-sec);
i { font-size: 32px; margin-bottom: 12px; display: block; opacity: 0.5; }
p { margin: 0; font-size: 14px; }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
<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> SYSTEM ADMIN
</div>
<h1>Fornecer Usuário para Cliente</h1>
<p>Selecione um tenant-cliente e crie credenciais de acesso sem misturar tenants.</p>
</header>
<div class="card-body">
<div class="alert-box error" *ngIf="tenantsError">
{{ tenantsError }}
</div>
<div class="alert-box success" *ngIf="successMessage">
{{ successMessage }}
<div class="mt-1" *ngIf="createdUser">
<small>
UserId: <strong>{{ createdUser.userId }}</strong> | TenantId:
<strong>{{ createdUser.tenantId }}</strong>
</small>
</div>
</div>
<div class="alert-box error" *ngIf="submitErrors.length">
<strong>Falha ao criar usuário:</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 (Tenant)</label>
<div class="select-row">
<select
id="tenantId"
formControlName="tenantId"
class="form-control"
[disabled]="tenantsLoading || !tenants.length"
>
<option value="">Selecione um cliente...</option>
<option
*ngFor="let tenant of tenants; trackBy: trackByTenantId"
[value]="tenant.tenantId"
>
{{ tenant.nomeOficial }}
</option>
</select>
<button type="button" class="btn btn-ghost" (click)="loadTenants()" [disabled]="tenantsLoading">
{{ tenantsLoading ? 'Atualizando...' : 'Atualizar lista' }}
</button>
</div>
<small class="field-help">Origem: {{ sourceType }} (apenas tenants ativos).</small>
<small class="field-error" *ngIf="hasFieldError('tenantId', 'required')">
Selecione um tenant-cliente.
</small>
</div>
<div class="form-field">
<label for="name">Nome (opcional)</label>
<input id="name" type="text" class="form-control" formControlName="name" placeholder="Nome do usuário" />
</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 class="form-field span-2">
<label>Roles do usuário</label>
<div class="roles-grid">
<label class="role-item" *ngFor="let role of roleOptions; trackBy: trackByRoleValue">
<input
type="checkbox"
[checked]="isRoleSelected(role.value)"
(change)="toggleRole(role.value, $any($event.target).checked)"
/>
<div class="role-content">
<strong>{{ role.label }}</strong>
<span>{{ role.description }}</span>
</div>
</label>
</div>
<small class="field-error" *ngIf="selectedRoles.length === 0">
Selecione ao menos uma role.
</small>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="submitting || provisionForm.invalid">
<span *ngIf="!submitting">Criar usuário para cliente</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,255 @@
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 {
SystemAdminService,
SystemTenantDto,
CreateSystemTenantUserResponse,
} from '../../services/system-admin.service';
type RoleOption = {
value: string;
label: string;
description: string;
};
@Component({
selector: 'app-system-provision-user',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './system-provision-user.html',
styleUrls: ['./system-provision-user.scss'],
})
export class SystemProvisionUserPage implements OnInit {
readonly roleOptions: RoleOption[] = [
{ value: 'sysadmin', label: 'SysAdmin', description: 'Acesso administrativo global do sistema (apenas SystemTenant).' },
{ value: 'gestor', label: 'Gestor', description: 'Acesso global de gestão, sem permissões administrativas.' },
{ value: 'cliente', label: 'Cliente', description: 'Acesso restrito ao tenant do cliente.' },
];
readonly sourceType = 'MobileLines.Cliente';
provisionForm: FormGroup;
tenants: SystemTenantDto[] = [];
tenantsLoading = false;
tenantsError = '';
submitting = false;
submitErrors: string[] = [];
successMessage = '';
createdUser: CreateSystemTenantUserResponse | null = null;
constructor(
private fb: FormBuilder,
private systemAdminService: SystemAdminService
) {
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.systemAdminService
.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.tenantsLoading = false;
},
error: (err: HttpErrorResponse) => {
this.tenantsLoading = false;
this.tenantsError = this.extractErrorMessage(
err,
'Não foi possível carregar os clientes. Verifique se a conta possui role sysadmin.'
);
},
});
}
isRoleSelected(role: string): boolean {
const selected = this.selectedRoles;
return selected.includes(role);
}
toggleRole(role: string, checked: boolean): void {
const current = this.selectedRoles;
const next = checked
? Array.from(new Set([...current, role]))
: current.filter((value) => value !== role);
this.rolesControl.setValue(next);
this.rolesControl.markAsDirty();
this.rolesControl.markAsTouched();
}
onSubmit(): void {
if (this.submitting) return;
this.successMessage = '';
this.submitErrors = [];
this.createdUser = null;
if (this.provisionForm.invalid || this.selectedRoles.length === 0) {
this.provisionForm.markAllAsTouched();
if (this.selectedRoles.length === 0) {
this.submitErrors = ['Selecione ao menos uma role para o usuário.'];
}
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.systemAdminService
.createTenantUser(tenantId, {
name: nameRaw,
email,
password,
roles: this.selectedRoles,
})
.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 = `Usuário ${created.email} criado 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;
}
trackByRoleValue(_: number, role: RoleOption): string {
return role.value;
}
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']);
}
get selectedRoles(): string[] {
const roles = this.rolesControl.value;
return Array.isArray(roles) ? roles : [];
}
get rolesControl(): AbstractControl<string[] | null, string[] | null> {
return this.provisionForm.get('roles') as AbstractControl<string[] | null, string[] | null>;
}
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 });
}
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 o usuário 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

@ -76,7 +76,7 @@
<span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
</span>
<input class="form-control" placeholder="Pesquisar (linha, ICCID, motivo, observação)..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
<i class="bi bi-x-lg"></i>
</button>
@ -85,13 +85,8 @@
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -320,13 +315,7 @@
<!-- ✅ Cliente (GERAL) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<select class="form-control form-control-sm"
[(ngModel)]="selectedCliente"
(change)="onClienteChange()"
[disabled]="loadingClients">
<option value="">Selecione...</option>
<option *ngFor="let c of clientsFromGeral" [value]="c">{{ c }}</option>
</select>
<app-select class="form-control" size="sm" [options]="clientsFromGeral" [(ngModel)]="selectedCliente" (ngModelChange)="onClienteChange()" placeholder="Selecione..."></app-select>
<small class="hint" *ngIf="loadingClients">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
@ -336,15 +325,7 @@
<!-- ✅ Linha do Cliente (GERAL) -->
<div class="form-field span-2">
<label>Linha do Cliente (GERAL)</label>
<select class="form-control form-control-sm"
[(ngModel)]="selectedLineId"
(change)="onLineChange()"
[disabled]="!selectedCliente || loadingLines">
<option value="">Selecione...</option>
<option *ngFor="let l of linesFromClient" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<app-select class="form-control" size="sm" [options]="linesFromClient" labelKey="label" valueKey="id" [(ngModel)]="selectedLineId" (ngModelChange)="onLineChange()" [disabled]="!selectedCliente || loadingLines" placeholder="Selecione a linha do cliente..."></app-select>
<small class="hint" *ngIf="loadingLines">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
@ -392,3 +373,5 @@
</div>
</div>

View File

@ -250,7 +250,7 @@
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.search-group {
max-width: 380px;
max-width: 270px;
border-radius: 12px;
overflow: hidden;
display: flex;

View File

@ -9,7 +9,9 @@ import {
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { environment } from '../../../environments/environment';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
@ -49,11 +51,12 @@ interface LineOptionDto {
cliente: string | null;
usuario: string | null;
skil: string | null;
label?: string;
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './troca-numero.html',
styleUrls: ['./troca-numero.scss']
})
@ -69,10 +72,18 @@ export class TrocaNumero implements AfterViewInit {
private cdr: ChangeDetectorRef
) {}
private readonly apiBase = 'https://localhost:7205/api/trocanumero';
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/trocanumero`;
})();
/** ✅ base do GERAL (para buscar clientes/linhas no modal) */
private readonly linesApiBase = 'https://localhost:7205/api/lines';
private readonly linesApiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
// ====== DATA ======
groups: GroupItem[] = [];
@ -92,6 +103,7 @@ export class TrocaNumero implements AfterViewInit {
private searchTimer: any = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// ====== EDIT MODAL ======
@ -357,7 +369,10 @@ export class TrocaNumero implements AfterViewInit {
this.http.get<LineOptionDto[]>(`${this.linesApiBase}/by-client`, { params }).subscribe({
next: (res) => {
this.linesFromClient = (res ?? []);
this.linesFromClient = (res ?? []).map((x) => ({
...x,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}));
this.loadingLines = false;
this.cdr.detectChanges();
},

View File

@ -23,7 +23,11 @@
<h5 class="title">GESTÃO DE VIGÊNCIA</h5>
<small class="subtitle">Controle de contratos e fidelização</small>
</div>
<div class="d-flex gap-2 justify-content-end"></div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button *ngIf="isAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
<i class="bi bi-plus-circle me-1"></i> Nova Vigência
</button>
</div>
</div>
<div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'">
@ -39,29 +43,34 @@
<span class="lbl text-danger">Total Vencidos</span>
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
</div>
<div class="kpi">
<span class="lbl text-brand">Valor Total</span>
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
</div>
</div>
<div class="controls mt-3 mb-2 d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div class="search-group flex-grow-1" style="max-width: 400px;">
<div class="position-relative">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" style="position: absolute; left: 14px; top: 10px; color: var(--muted);"></i>
<input class="form-control ps-5" placeholder="Pesquisar cliente..." [(ngModel)]="search" (keyup.enter)="fetch(1)" [disabled]="loading">
<button *ngIf="search" class="btn btn-link position-absolute end-0 top-0 text-muted" (click)="clearFilters()"><i class="bi bi-x-circle"></i></button>
</div>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
</span>
<input
type="search"
class="form-control"
placeholder="Pesquisar..."
inputmode="search"
enterkeyhint="search"
autocomplete="off"
[(ngModel)]="search"
(ngModelChange)="onSearchChange()">
<button
class="btn btn-outline-secondary btn-clear"
type="button"
*ngIf="search"
(click)="clearFilters()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="font-size: 0.75rem;">Itens por pág:</span>
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="fetch(1)" [disabled]="loading" style="width: 80px;">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="fetch(1)" [disabled]="loading" style="width: 80px;"></app-select>
</div>
</div>
</div>
@ -93,7 +102,7 @@
<div class="group-body" *ngIf="expandedGroup === g.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<div class="group-body-top d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Linhas do Cliente</small>
<span class="chip-muted">Total: {{ g.total | currency:'BRL' }}</span>
</div>
@ -114,7 +123,7 @@
<th>EFETIVAÇÃO</th>
<th>VENCIMENTO</th>
<th class="text-end">TOTAL</th>
<th style="min-width: 80px;">AÇÕES</th>
<th class="actions-col">AÇÕES</th>
</tr>
</thead>
<tbody>
@ -140,6 +149,8 @@
<td>
<div class="action-group justify-content-center">
<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>
</div>
</td>
</tr>
@ -169,51 +180,262 @@
</div>
</section>
<div class="lg-backdrop" *ngIf="detailsOpen" (click)="closeDetails()"></div>
<div class="lg-backdrop" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
<div class="lg-modal" *ngIf="detailsOpen">
<div class="lg-modal-card">
<div class="modal-header d-flex justify-content-between align-items-center p-3 border-bottom">
<h6 class="mb-0 fw-bold"><i class="bi bi-card-list me-2 text-brand"></i> Detalhes da Linha</h6>
<button class="btn-close" (click)="closeDetails()"></button>
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
Detalhes da Vigência
</div>
<div class="modal-body p-4 bg-light-gray">
<div class="form-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Cliente</small>
<span class="fw-bold text-dark">{{ selectedRow?.cliente }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Linha</small>
<span class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Conta</small>
<span>{{ selectedRow?.conta || '-' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Usuário</small>
<span>{{ selectedRow?.usuario || '-' }}</span>
</div>
<div class="d-flex flex-column span-2" style="grid-column: span 2;">
<small class="text-muted fw-bold text-uppercase">Plano</small>
<span class="p-2 bg-white border rounded">{{ selectedRow?.planoContrato || '-' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Efetivação</small>
<span>{{ selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy' }}</span>
</div>
<div class="d-flex flex-column">
<small class="text-muted fw-bold text-uppercase">Término</small>
<span class="text-danger fw-bold">{{ selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy' }}</span>
</div>
<div class="d-flex flex-column span-2 text-end pt-2 border-top">
<small class="text-muted fw-bold text-uppercase">Valor Total</small>
<span class="fw-black text-brand fs-4">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div>
</div>
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="details-dashboard">
<div class="detail-box">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da Linha</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item span-2">
<span class="lbl">Cliente</span>
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Linha</span>
<span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Conta</span>
<span class="val">{{ selectedRow?.conta || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Usuário</span>
<span class="val">{{ selectedRow?.usuario || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Plano</span>
<span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Efetivação</span>
<span class="val">{{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Término</span>
<span class="val" [class.text-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Situação</span>
<span class="status-pill" [class.is-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
</div>
</div>
</div>
<!-- CREATE MODAL -->
<div class="lg-modal" *ngIf="createOpen">
<div class="lg-modal-card modal-xl create-modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
Nova Vigência
</div>
</div>
</div>
<button class="btn-icon" (click)="closeCreate()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<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>
<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>
<app-select
class="form-select"
size="sm"
[options]="lineOptionsCreate"
labelKey="label"
valueKey="id"
[(ngModel)]="createModel.mobileLineId"
(ngModelChange)="onCreateLineChange()"
[disabled]="createLinesLoading || !createModel.selectedClient"
></app-select>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></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"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="createModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /></div>
<div class="form-field span-2">
<label>Plano</label>
<app-select
*ngIf="planOptions.length > 0"
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
></app-select>
<input
*ngIf="planOptions.length === 0"
class="form-control form-control-sm"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onCreatePlanChange()"
/>
</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" />
<small class="field-hint">Gerado automaticamente pelo sistema</small>
</div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</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"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeCreate()">Cancelar</button>
<button class="btn btn-brand btn-sm" [disabled]="createSaving" (click)="saveCreate()">
{{ createSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- EDIT MODAL -->
<div class="lg-modal" *ngIf="editOpen">
<div class="lg-modal-card modal-xl" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span>
Editar Vigência
</div>
<button class="btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray" *ngIf="editModel">
<div class="edit-sections">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></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"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.conta" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
<div class="form-field span-2"><label>Plano</label><input class="form-control form-control-sm" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onEditPlanChange()" /></div>
</div>
</div>
</details>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Vigência e Valor</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"><label>Efetivação</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editEfetivacao" /></div>
<div class="form-field"><label>Término</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editTermino" /></div>
<div class="form-field span-2"><label>Total</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.total" /></div>
</div>
</div>
</details>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
{{ editSaving ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
<!-- DELETE MODAL -->
<div class="lg-modal" *ngIf="deleteOpen">
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span>
Remover Vigência
</div>
<button class="btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body bg-light-gray">
<div class="confirm-delete">
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
<p class="mb-0">Confirma remover o registro <strong>{{ deleteTarget?.linha }}</strong>?</p>
</div>
</div>
<div class="modal-footer p-3 text-end border-top">
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,34 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
import { LinesService, MobileLineDetail } from '../../services/lines.service';
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger';
type ViewMode = 'lines' | 'groups';
interface LineOptionDto {
id: string;
item: number;
linha: string | null;
usuario: string | null;
label?: string;
}
@Component({
selector: 'app-vigencia',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'],
})
export class VigenciaComponent implements OnInit {
export class VigenciaComponent implements OnInit, OnDestroy {
loading = false;
errorMsg = '';
@ -27,6 +40,7 @@ export class VigenciaComponent implements OnInit {
// Paginação
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// Ordenação
@ -44,7 +58,6 @@ export class VigenciaComponent implements OnInit {
kpiTotalClientes = 0;
kpiTotalLinhas = 0;
kpiTotalVencidos = 0;
kpiValorTotal = 0;
// === ACORDEÃO ===
expandedGroup: string | null = null;
@ -54,18 +67,63 @@ export class VigenciaComponent implements OnInit {
// UI
detailsOpen = false;
selectedRow: VigenciaRow | null = null;
editOpen = false;
editSaving = false;
editModel: VigenciaRow | null = null;
editEfetivacao = '';
editTermino = '';
editingId: string | null = null;
deleteOpen = false;
deleteTarget: VigenciaRow | null = null;
createOpen = false;
createSaving = false;
createModel: any = {
selectedClient: '',
mobileLineId: '',
item: '',
conta: '',
linha: '',
cliente: '',
usuario: '',
planoContrato: '',
total: null
};
createEfetivacao = '';
createTermino = '';
lineOptionsCreate: LineOptionDto[] = [];
createClientsLoading = false;
createLinesLoading = false;
clientsFromGeral: string[] = [];
planOptions: string[] = [];
isAdmin = false;
toastOpen = false;
toastMessage = '';
toastType: ToastType = 'success';
private toastTimer: any = null;
private searchTimer: any = null;
constructor(private vigenciaService: VigenciaService) {}
constructor(
private vigenciaService: VigenciaService,
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService
) {}
ngOnInit(): void {
this.isAdmin = this.authService.hasRole('sysadmin');
this.loadClients();
this.loadPlanRules();
this.fetch(1);
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
}
setView(mode: ViewMode): void {
if (this.viewMode === mode) return;
this.viewMode = mode;
@ -83,6 +141,15 @@ export class VigenciaComponent implements OnInit {
});
}
private async loadPlanRules() {
try {
await this.planAutoFill.load();
this.planOptions = this.planAutoFill.getPlanOptions();
} catch {
this.planOptions = [];
}
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
}
@ -118,7 +185,6 @@ export class VigenciaComponent implements OnInit {
this.kpiTotalClientes = res.kpis.totalClientes;
this.kpiTotalLinhas = res.kpis.totalLinhas;
this.kpiTotalVencidos = res.kpis.totalVencidos;
this.kpiValorTotal = res.kpis.valorTotal;
this.loading = false;
},
@ -197,10 +263,299 @@ export class VigenciaComponent implements OnInit {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
clearFilters() { this.search = ''; this.fetch(1); }
onSearchChange() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.fetch(1), 300);
}
clearFilters() {
this.search = '';
if (this.searchTimer) clearTimeout(this.searchTimer);
this.fetch(1);
}
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; }
openEdit(r: VigenciaRow) {
if (!this.isAdmin) return;
this.editingId = r.id;
this.editModel = { ...r };
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
this.editTermino = this.toDateInput(r.dtTerminoFidelizacao);
this.editOpen = true;
}
closeEdit() {
this.editOpen = false;
this.editSaving = false;
this.editModel = null;
this.editEfetivacao = '';
this.editTermino = '';
this.editingId = null;
}
saveEdit() {
if (!this.editModel || !this.editingId) return;
this.editSaving = true;
const payload: UpdateVigenciaRequest = {
item: this.toNullableNumber(this.editModel.item),
conta: this.editModel.conta,
linha: this.editModel.linha,
cliente: this.editModel.cliente,
usuario: this.editModel.usuario,
planoContrato: this.editModel.planoContrato,
dtEfetivacaoServico: this.dateInputToIso(this.editEfetivacao),
dtTerminoFidelizacao: this.dateInputToIso(this.editTermino),
total: this.toNullableNumber(this.editModel.total)
};
this.vigenciaService.update(this.editingId, payload).subscribe({
next: () => {
this.editSaving = false;
this.closeEdit();
this.fetch();
this.showToast('Registro atualizado!', 'success');
},
error: () => {
this.editSaving = false;
this.showToast('Erro ao salvar.', 'danger');
}
});
}
// ==========================
// CREATE
// ==========================
openCreate() {
if (!this.isAdmin) return;
this.resetCreateModel();
this.createOpen = true;
this.preloadGeralClients();
}
closeCreate() {
this.createOpen = false;
this.createSaving = false;
this.createModel = null;
}
private resetCreateModel() {
this.createModel = {
selectedClient: '',
mobileLineId: '',
item: '',
conta: '',
linha: '',
cliente: '',
usuario: '',
planoContrato: '',
total: null
};
this.createEfetivacao = '';
this.createTermino = '';
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.conta = '';
this.createModel.usuario = '';
this.createModel.planoContrato = '';
this.createModel.total = null;
this.createModel.cliente = c;
this.lineOptionsCreate = [];
if (c) this.loadLinesForClient(c);
}
private loadLinesForClient(cliente: string) {
const c = (cliente ?? '').trim();
if (!c) return;
this.createLinesLoading = true;
this.linesService.getLinesByClient(c).subscribe({
next: (items: any[]) => {
const mapped: LineOptionDto[] = (items ?? [])
.filter(x => !!String(x?.id ?? '').trim())
.map(x => ({
id: String(x.id),
item: Number(x.item ?? 0),
linha: x.linha ?? null,
usuario: x.usuario ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());
this.lineOptionsCreate = mapped;
this.createLinesLoading = false;
},
error: () => {
this.lineOptionsCreate = [];
this.createLinesLoading = false;
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
}
});
}
onCreateLineChange() {
const id = String(this.createModel.mobileLineId ?? '').trim();
if (!id) return;
this.linesService.getById(id).subscribe({
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
});
}
private applyLineDetailToCreate(d: MobileLineDetail) {
this.createModel.linha = d.linha ?? '';
this.createModel.conta = d.conta ?? '';
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
this.createModel.usuario = d.usuario ?? '';
this.createModel.planoContrato = d.planoContrato ?? '';
this.createEfetivacao = this.toDateInput(d.dtEfetivacaoServico ?? null);
this.createTermino = this.toDateInput(d.dtTerminoFidelizacao ?? null);
this.ensurePlanOption(this.createModel.planoContrato);
if (!String(this.createModel.item ?? '').trim() && d.item) {
this.createModel.item = String(d.item);
}
this.onCreatePlanChange();
}
onCreatePlanChange() {
this.ensurePlanOption(this.createModel?.planoContrato);
this.applyPlanSuggestion(this.createModel);
}
onEditPlanChange() {
if (!this.editModel) return;
this.ensurePlanOption(this.editModel?.planoContrato);
this.applyPlanSuggestion(this.editModel);
}
private applyPlanSuggestion(model: any) {
const plan = (model?.planoContrato ?? '').toString().trim();
if (!plan) return;
const suggestion = this.planAutoFill.suggest(plan);
if (!suggestion) return;
if (suggestion.valorPlano != null) {
model.total = suggestion.valorPlano;
}
}
private ensurePlanOption(plan: any) {
const p = (plan ?? '').toString().trim();
if (!p) return;
if (!this.planOptions.includes(p)) {
this.planOptions = [p, ...this.planOptions];
}
}
saveCreate() {
if (!this.createModel) return;
this.applyPlanSuggestion(this.createModel);
const payload = {
item: this.toNullableNumber(this.createModel.item),
conta: this.createModel.conta,
linha: this.createModel.linha,
cliente: this.createModel.cliente,
usuario: this.createModel.usuario,
planoContrato: this.createModel.planoContrato,
dtEfetivacaoServico: this.dateInputToIso(this.createEfetivacao),
dtTerminoFidelizacao: this.dateInputToIso(this.createTermino),
total: this.toNullableNumber(this.createModel.total)
};
this.createSaving = true;
this.vigenciaService.create(payload).subscribe({
next: () => {
this.createSaving = false;
this.closeCreate();
this.fetch();
this.showToast('Vigência criada com sucesso!', 'success');
},
error: () => {
this.createSaving = false;
this.showToast('Erro ao criar vigência.', 'danger');
}
});
}
openDelete(r: VigenciaRow) {
if (!this.isAdmin) return;
this.deleteTarget = r;
this.deleteOpen = true;
}
cancelDelete() {
this.deleteOpen = false;
this.deleteTarget = null;
}
async confirmDelete() {
if (!this.deleteTarget) return;
if (!(await confirmDeletionWithTyping('este registro de vigência'))) return;
const id = this.deleteTarget.id;
this.vigenciaService.remove(id).subscribe({
next: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.fetch();
this.showToast('Registro removido.', 'success');
},
error: () => {
this.deleteOpen = false;
this.deleteTarget = null;
this.showToast('Erro ao remover.', 'danger');
}
});
}
private toDateInput(value: string | null): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
private dateInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
private toNullableNumber(value: any): number | null {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isNaN(n) ? null : n;
}
handleError(err: HttpErrorResponse, msg: string) {
this.loading = false;
this.expandedLoading = false;
@ -214,4 +569,4 @@ export class VigenciaComponent implements OnInit {
this.toastTimer = setTimeout(() => this.toastOpen = false, 3000);
}
hideToast() { this.toastOpen = false; }
}
}

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { BehaviorSubject } from 'rxjs';
import { tap } from 'rxjs/operators';
export interface RegisterPayload {
@ -16,31 +17,220 @@ export interface LoginPayload {
password: string;
}
export interface LoginOptions {
rememberMe?: boolean;
}
export interface LoginResponse {
token?: string;
accessToken?: string;
}
export interface AuthUserProfile {
id: string;
nome: string;
email: string;
tenantId: string;
roles: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private baseUrl = `${environment.apiUrl}/auth`;
private userProfileSubject = new BehaviorSubject<AuthUserProfile | null>(null);
readonly userProfile$ = this.userProfileSubject.asObservable();
private readonly tokenStorageKey = 'token';
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
private readonly rememberMeHours = 6;
constructor(private http: HttpClient) {}
constructor(private http: HttpClient) {
this.syncUserProfileFromToken();
}
register(payload: RegisterPayload) {
return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload)
.pipe(tap(r => localStorage.setItem('token', r.token)));
.pipe(tap(r => this.setToken(r.token)));
}
login(payload: LoginPayload) {
return this.http.post<{ token: string }>(`${this.baseUrl}/login`, payload)
.pipe(tap(r => localStorage.setItem('token', r.token)));
login(payload: LoginPayload, options?: LoginOptions) {
return this.http.post<LoginResponse>(`${this.baseUrl}/login`, payload)
.pipe(
tap((r) => {
const token = this.resolveLoginToken(r);
if (!token) return;
this.setToken(token, options?.rememberMe ?? false);
})
);
}
logout() {
localStorage.removeItem('token');
if (typeof window === 'undefined') {
this.userProfileSubject.next(null);
return;
}
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
this.userProfileSubject.next(null);
}
setToken(token: string, rememberMe = false) {
if (typeof window === 'undefined') return;
this.clearTokenStorage(localStorage);
this.clearTokenStorage(sessionStorage);
if (rememberMe) {
const expiresAt = Date.now() + this.rememberMeHours * 60 * 60 * 1000;
localStorage.setItem(this.tokenStorageKey, token);
localStorage.setItem(this.tokenExpiresAtKey, String(expiresAt));
} else {
sessionStorage.setItem(this.tokenStorageKey, token);
}
this.syncUserProfileFromToken();
}
get token(): string | null {
return localStorage.getItem('token');
if (typeof window === 'undefined') return null;
this.cleanupExpiredRememberSession();
const sessionToken = sessionStorage.getItem(this.tokenStorageKey);
if (sessionToken) return sessionToken;
return localStorage.getItem(this.tokenStorageKey);
}
isLoggedIn(): boolean {
return !!this.token;
}
get currentUserProfile(): AuthUserProfile | null {
return this.userProfileSubject.value;
}
syncUserProfileFromToken() {
this.userProfileSubject.next(this.buildProfileFromToken());
}
updateUserProfile(profile: Pick<AuthUserProfile, 'nome' | 'email'>) {
const current = this.userProfileSubject.value;
if (!current) return;
this.userProfileSubject.next({
...current,
nome: profile.nome.trim(),
email: profile.email.trim().toLowerCase(),
});
}
getTokenPayload(): Record<string, any> | null {
const token = this.token;
if (!token) return null;
try {
const payload = token.split('.')[1];
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(json);
} catch {
return null;
}
}
getRoles(): string[] {
const payload = this.getTokenPayload();
if (!payload) return [];
return this.extractRoles(payload);
}
private extractRoles(payload: Record<string, any>): string[] {
const possibleKeys = [
'role',
'roles',
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
];
let roles: string[] = [];
for (const key of possibleKeys) {
const value = payload[key];
if (!value) continue;
if (Array.isArray(value)) roles = roles.concat(value);
else roles.push(String(value));
}
return roles.map(r => r.toLowerCase());
}
private buildProfileFromToken(): AuthUserProfile | null {
const payload = this.getTokenPayload();
if (!payload) return null;
const id = String(
payload['sub'] ??
payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] ??
''
).trim();
const nome = String(payload['name'] ?? '').trim();
const email = String(
payload['email'] ??
payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] ??
''
).trim().toLowerCase();
const tenantId = String(
payload['tenantId'] ??
payload['tenant'] ??
payload['TenantId'] ??
''
).trim();
if (!id || !tenantId) return null;
return {
id,
nome,
email,
tenantId,
roles: this.extractRoles(payload),
};
}
hasRole(role: string): boolean {
const target = (role || '').toLowerCase();
if (!target) return false;
return this.getRoles().includes(target);
}
private cleanupExpiredRememberSession() {
const token = localStorage.getItem(this.tokenStorageKey);
if (!token) return;
const expiresAtRaw = localStorage.getItem(this.tokenExpiresAtKey);
if (!expiresAtRaw) {
this.clearTokenStorage(localStorage);
return;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt)) {
this.clearTokenStorage(localStorage);
return;
}
if (Date.now() > expiresAt) {
this.clearTokenStorage(localStorage);
}
}
private clearTokenStorage(storage: Storage) {
storage.removeItem(this.tokenStorageKey);
storage.removeItem(this.tokenExpiresAtKey);
}
private resolveLoginToken(response: LoginResponse | null | undefined): string | null {
const raw = response?.token ?? response?.accessToken ?? null;
const token = (raw ?? '').toString().trim();
return token || null;
}
}

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { environment } from '../../environments/environment';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
@ -34,6 +35,22 @@ export interface BillingItem {
aparelho?: string | null;
formaPagamento?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface BillingUpdateRequest {
tipo?: string;
item?: number | null;
cliente?: string | null;
qtdLinhas?: number | null;
franquiaVivo?: number | null;
valorContratoVivo?: number | null;
franquiaLine?: number | null;
valorContratoLine?: number | null;
lucro?: number | null;
aparelho?: string | null;
formaPagamento?: string | null;
}
export interface BillingQuery {
@ -55,9 +72,13 @@ export interface ApiPagedResult<T> {
@Injectable({ providedIn: 'root' })
export class BillingService {
private readonly baseUrl = 'https://localhost:7205/api/billing';
private readonly baseUrl: string;
constructor(private http: HttpClient) {}
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseUrl = `${apiBase}/billing`;
}
getPaged(q: BillingQuery): Observable<ApiPagedResult<BillingItem>> {
let params = new HttpParams()
@ -84,4 +105,16 @@ export class BillingService {
return this.getPaged(q).pipe(map((res) => res.items ?? []));
}
getById(id: string): Observable<BillingItem> {
return this.http.get<BillingItem>(`${this.baseUrl}/${id}`);
}
update(id: string, payload: BillingUpdateRequest): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}`, payload);
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}

View File

@ -0,0 +1,152 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type SortDir = 'asc' | 'desc';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface ChipVirgemListDto {
id: string;
item: number;
numeroDoChip: string | null;
observacoes: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UpdateChipVirgemRequest {
item?: number | null;
numeroDoChip?: string | null;
observacoes?: string | null;
}
export interface CreateChipVirgemRequest extends UpdateChipVirgemRequest {}
export interface ControleRecebidoListDto {
id: string;
ano: number | null;
item: number | null;
notaFiscal: string | null;
chip: string | null;
serial: string | null;
conteudoDaNf: string | null;
numeroDaLinha: string | null;
valorUnit: number | null;
valorDaNf: number | null;
dataDaNf: string | null;
dataDoRecebimento: string | null;
quantidade: number | null;
isResumo: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UpdateControleRecebidoRequest {
ano?: number | null;
item?: number | null;
notaFiscal?: string | null;
chip?: string | null;
serial?: string | null;
conteudoDaNf?: string | null;
numeroDaLinha?: string | null;
valorUnit?: number | null;
valorDaNf?: number | null;
dataDaNf?: string | null;
dataDoRecebimento?: string | null;
quantidade?: number | null;
isResumo?: boolean | null;
}
export interface CreateControleRecebidoRequest extends UpdateControleRecebidoRequest {}
@Injectable({ providedIn: 'root' })
export class ChipsControleService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getChipsVirgens(opts: {
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
}): Observable<PagedResult<ChipVirgemListDto>> {
let params = new HttpParams();
if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim());
params = params.set('page', String(opts.page ?? 1));
params = params.set('pageSize', String(opts.pageSize ?? 20));
params = params.set('sortBy', (opts.sortBy ?? 'item').trim());
params = params.set('sortDir', opts.sortDir ?? 'asc');
return this.http.get<PagedResult<ChipVirgemListDto>>(`${this.baseApi}/chips-virgens`, { params });
}
getChipVirgemById(id: string): Observable<ChipVirgemListDto> {
return this.http.get<ChipVirgemListDto>(`${this.baseApi}/chips-virgens/${id}`);
}
updateChipVirgem(id: string, payload: UpdateChipVirgemRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/chips-virgens/${id}`, payload);
}
createChipVirgem(payload: CreateChipVirgemRequest): Observable<ChipVirgemListDto> {
return this.http.post<ChipVirgemListDto>(`${this.baseApi}/chips-virgens`, payload);
}
removeChipVirgem(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/chips-virgens/${id}`);
}
getControleRecebidos(opts: {
ano?: number | string | null;
isResumo?: boolean | string | null;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
}): Observable<PagedResult<ControleRecebidoListDto>> {
let params = new HttpParams();
const ano = opts.ano ?? '';
const resumo = opts.isResumo ?? '';
if (String(ano).trim()) params = params.set('ano', String(ano).trim());
if (String(resumo).trim()) params = params.set('isResumo', String(resumo).trim());
if (opts.search && opts.search.trim()) params = params.set('search', opts.search.trim());
params = params.set('page', String(opts.page ?? 1));
params = params.set('pageSize', String(opts.pageSize ?? 20));
params = params.set('sortBy', (opts.sortBy ?? 'ano').trim());
params = params.set('sortDir', opts.sortDir ?? 'asc');
return this.http.get<PagedResult<ControleRecebidoListDto>>(`${this.baseApi}/controle-recebidos`, { params });
}
getControleRecebidoById(id: string): Observable<ControleRecebidoListDto> {
return this.http.get<ControleRecebidoListDto>(`${this.baseApi}/controle-recebidos/${id}`);
}
updateControleRecebido(id: string, payload: UpdateControleRecebidoRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/controle-recebidos/${id}`, payload);
}
createControleRecebido(payload: CreateControleRecebidoRequest): Observable<ControleRecebidoListDto> {
return this.http.post<ControleRecebidoListDto>(`${this.baseApi}/controle-recebidos`, payload);
}
removeControleRecebido(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/controle-recebidos/${id}`);
}
}

View File

@ -17,6 +17,10 @@ export interface UserDataRow {
item: number;
linha: string | null;
cliente: string | null;
tipoPessoa?: string | null;
nome?: string | null;
razaoSocial?: string | null;
cnpj?: string | null;
cpf: string | null;
email: string | null;
celular: string | null;
@ -26,10 +30,30 @@ export interface UserDataRow {
dataNascimento: string | null;
}
export interface UpdateUserDataRequest {
item?: number | null;
linha?: string | null;
cliente?: string | null;
tipoPessoa?: string | null;
nome?: string | null;
razaoSocial?: string | null;
cnpj?: string | null;
cpf?: string | null;
rg?: string | null;
dataNascimento?: string | null;
email?: string | null;
endereco?: string | null;
celular?: string | null;
telefoneFixo?: string | null;
}
export interface CreateUserDataRequest extends UpdateUserDataRequest {}
export interface UserDataClientGroup {
cliente: string;
totalRegistros: number;
comCpf: number;
comCnpj: number;
comEmail: number;
}
@ -37,6 +61,7 @@ export interface UserDataKpis {
totalRegistros: number;
clientesUnicos: number;
comCpf: number;
comCnpj: number;
comEmail: number;
}
@ -50,12 +75,13 @@ export class DadosUsuariosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
getGroups(opts: {
search?: string;
tipo?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@ -63,6 +89,7 @@ export class DadosUsuariosService {
}): Observable<UserDataGroupResponse> {
let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search);
if (opts.tipo) params = params.set('tipo', opts.tipo);
params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 10));
@ -75,6 +102,7 @@ export class DadosUsuariosService {
getRows(opts: {
search?: string;
client?: string;
tipo?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@ -83,6 +111,7 @@ export class DadosUsuariosService {
let params = new HttpParams();
if (opts.search) params = params.set('search', opts.search);
if (opts.client) params = params.set('client', opts.client);
if (opts.tipo) params = params.set('tipo', opts.tipo);
params = params.set('page', String(opts.page || 1));
params = params.set('pageSize', String(opts.pageSize || 20));
@ -92,11 +121,25 @@ export class DadosUsuariosService {
return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params });
}
getClients(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`);
getClients(tipo?: string): Observable<string[]> {
let params = new HttpParams();
if (tipo) params = params.set('tipo', tipo);
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`, { params });
}
getById(id: string): Observable<UserDataRow> {
return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`);
}
}
update(id: string, payload: UpdateUserDataRequest): Observable<void> {
return this.http.put<void>(`${this.baseApi}/user-data/${id}`, payload);
}
create(payload: CreateUserDataRequest): Observable<UserDataRow> {
return this.http.post<UserDataRow>(`${this.baseApi}/user-data`, payload);
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/user-data/${id}`);
}
}

View File

@ -0,0 +1,77 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type AuditChangeType = 'added' | 'modified' | 'removed';
export interface AuditFieldChangeDto {
field: string;
changeType: AuditChangeType;
oldValue?: string | null;
newValue?: string | null;
}
export interface AuditLogDto {
id: string;
occurredAtUtc: string;
action: AuditAction | string;
page: string;
entityName: string;
entityId?: string | null;
entityLabel?: string | null;
userId?: string | null;
userName?: string | null;
userEmail?: string | null;
requestPath?: string | null;
requestMethod?: string | null;
ipAddress?: string | null;
changes: AuditFieldChangeDto[];
}
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface HistoricoQuery {
pageName?: string;
action?: AuditAction | string;
entity?: string;
userId?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
@Injectable({ providedIn: 'root' })
export class HistoricoService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
list(params: HistoricoQuery): Observable<PagedResult<AuditLogDto>> {
let httpParams = new HttpParams();
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.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);
httpParams = httpParams.set('page', String(params.page || 1));
httpParams = httpParams.set('pageSize', String(params.pageSize || 10));
return this.http.get<PagedResult<AuditLogDto>>(`${this.baseApi}/historico`, { params: httpParams });
}
}

View File

@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface PagedResult<T> {
page: number;
@ -47,6 +48,8 @@ export interface MobileLineDetail extends MobileLineList {
solicitante?: string | null;
dataEntregaOpera?: string | null;
dataEntregaCliente?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
}
export interface LineOption {
@ -56,10 +59,13 @@ export interface LineOption {
@Injectable({ providedIn: 'root' })
export class LinesService {
// ✅ Mesma base do Swagger (evita redirect no preflight/CORS)
private baseUrl = 'https://localhost:7205/api/lines';
private readonly baseUrl: string;
constructor(private http: HttpClient) {}
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseUrl = `${apiBase}/lines`;
}
getLines(page: number, pageSize: number, search: string): Observable<PagedResult<MobileLineList>> {
let params = new HttpParams()

View File

@ -0,0 +1,114 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment';
export type NotificationTipo = 'AVencer' | 'Vencido';
export type NotificationDto = {
id: string;
tipo: NotificationTipo;
titulo: string;
mensagem: string;
data: string;
referenciaData?: string | null;
diasParaVencer?: number | null;
lida: boolean;
lidaEm?: string | null;
vigenciaLineId?: string | null;
cliente?: string | null;
linha?: string | null;
usuario?: string | null;
conta?: string | null;
planoContrato?: string | null;
dtEfetivacaoServico?: string | null;
dtTerminoFidelizacao?: string | null;
};
export type NotificationsEvent =
| { type: 'read'; ids: string[]; readAtIso: string }
| { type: 'unread'; ids: string[] }
| { type: 'readAll'; readAtIso: string }
| { type: 'unreadAll' }
| { type: 'reload' };
@Injectable({ providedIn: 'root' })
export class NotificationsService {
private readonly baseApi: string;
private readonly eventsSubject = new Subject<NotificationsEvent>();
readonly events$ = this.eventsSubject.asObservable();
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}
list(): Observable<NotificationDto[]> {
return this.http.get<NotificationDto[]>(`${this.baseApi}/notifications`);
}
markAsRead(id: string): Observable<void> {
const readAtIso = new Date().toISOString();
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {}).pipe(
tap(() => this.eventsSubject.next({ type: 'read', ids: [id], readAtIso }))
);
}
markAsUnread(id: string): Observable<void> {
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/unread`, {}).pipe(
tap(() => this.eventsSubject.next({ type: 'unread', ids: [id] }))
);
}
markAllAsRead(filter?: string, notificationIds?: string[]): Observable<void> {
let params = new HttpParams();
if (filter) params = params.set('filter', filter);
const body = notificationIds && notificationIds.length ? { notificationIds } : {};
const readAtIso = new Date().toISOString();
return this.http.patch<void>(`${this.baseApi}/notifications/read-all`, body, { params }).pipe(
tap(() => {
if (notificationIds && notificationIds.length) {
this.eventsSubject.next({ type: 'read', ids: notificationIds, readAtIso });
return;
}
// Se não sabemos o escopo (sem IDs), preferimos sinalizar que "todas" foram lidas.
// Para casos futuros de filtros sem IDs, o consumer pode optar por recarregar.
this.eventsSubject.next(filter ? { type: 'reload' } : { type: 'readAll', readAtIso });
})
);
}
markAllAsUnread(filter?: string, notificationIds?: string[]): Observable<void> {
let params = new HttpParams();
if (filter) params = params.set('filter', filter);
const body = notificationIds && notificationIds.length ? { notificationIds } : {};
return this.http.patch<void>(`${this.baseApi}/notifications/unread-all`, body, { params }).pipe(
tap(() => {
if (notificationIds && notificationIds.length) {
this.eventsSubject.next({ type: 'unread', ids: notificationIds });
return;
}
this.eventsSubject.next(filter ? { type: 'reload' } : { type: 'unreadAll' });
})
);
}
export(filter?: string, notificationIds?: string[]): Observable<HttpResponse<Blob>> {
let params = new HttpParams();
if (filter) params = params.set('filter', filter);
if (notificationIds && notificationIds.length) {
return this.http.post(`${this.baseApi}/notifications/export`, { notificationIds }, {
params,
observe: 'response',
responseType: 'blob'
});
}
return this.http.get(`${this.baseApi}/notifications/export`, {
params,
observe: 'response',
responseType: 'blob'
});
}
}

Some files were not shown because too many files have changed in this diff Show More