Merge e88762c6da into 2b81e7f594
This commit is contained in:
commit
f85ef1a626
28
angular.json
28
angular.json
|
|
@ -18,6 +18,7 @@
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
|
"outputPath": "dist/line-gestao-frontend",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js"
|
"zone.js"
|
||||||
],
|
],
|
||||||
|
|
@ -32,30 +33,37 @@
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"node_modules/bootstrap/dist/css/bootstrap.min.css"
|
"node_modules/bootstrap/dist/css/bootstrap.min.css"
|
||||||
],
|
]
|
||||||
"server": "src/main.server.ts",
|
|
||||||
"outputMode": "server",
|
|
||||||
"ssr": {
|
|
||||||
"entry": "src/server.ts"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.production.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "2MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "3MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "20kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "40kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
|
|
@ -6,8 +6,7 @@
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test"
|
||||||
"serve:ssr:line-gestao-frontend": "node dist/line-gestao-frontend/server/server.mjs"
|
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
|
|
@ -23,29 +22,25 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^20.3.0",
|
"@angular/common": "20.3.16",
|
||||||
"@angular/compiler": "^20.3.0",
|
"@angular/compiler": "20.3.16",
|
||||||
"@angular/core": "^20.3.0",
|
"@angular/core": "20.3.16",
|
||||||
"@angular/forms": "^20.3.0",
|
"@angular/forms": "20.3.16",
|
||||||
"@angular/platform-browser": "^20.3.0",
|
"@angular/platform-browser": "20.3.16",
|
||||||
"@angular/platform-server": "^20.3.0",
|
"@angular/router": "20.3.16",
|
||||||
"@angular/router": "^20.3.0",
|
|
||||||
"@angular/ssr": "^20.3.10",
|
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"express": "^5.1.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^20.3.10",
|
"@angular/build": "20.3.16",
|
||||||
"@angular/cli": "^20.3.10",
|
"@angular/cli": "20.3.16",
|
||||||
"@angular/compiler-cli": "^20.3.0",
|
"@angular/compiler-cli": "20.3.16",
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/express": "^5.0.1",
|
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"jasmine-core": "~5.9.0",
|
"jasmine-core": "~5.9.0",
|
||||||
|
|
@ -55,5 +50,8 @@
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"qs": "^6.14.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 154 KiB |
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
import {
|
import {
|
||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
|
LOCALE_ID,
|
||||||
provideBrowserGlobalErrorListeners,
|
provideBrowserGlobalErrorListeners,
|
||||||
provideZoneChangeDetection
|
provideZoneChangeDetection
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter, TitleStrategy } from '@angular/router';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
|
||||||
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||||
|
import { sessionInterceptor } from './interceptors/session.interceptor';
|
||||||
|
import { AppTitleStrategy } from './app-title.strategy';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
{ provide: LOCALE_ID, useValue: 'pt-BR' },
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(withEventReplay()),
|
{ provide: TitleStrategy, useClass: AppTitleStrategy },
|
||||||
|
|
||||||
// ✅ HttpClient com fetch + interceptor
|
// ✅ HttpClient com fetch + interceptor
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withFetch(),
|
withFetch(),
|
||||||
withInterceptors([authInterceptor])
|
withInterceptors([authInterceptor, sessionInterceptor])
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
|
||||||
{
|
|
||||||
path: '**',
|
|
||||||
renderMode: RenderMode.Prerender
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
@ -8,28 +8,49 @@ import { Mureg } from './pages/mureg/mureg';
|
||||||
import { Faturamento } from './pages/faturamento/faturamento';
|
import { Faturamento } from './pages/faturamento/faturamento';
|
||||||
|
|
||||||
import { authGuard } from './guards/auth.guard';
|
import { authGuard } from './guards/auth.guard';
|
||||||
|
import { adminGuard } from './guards/admin.guard';
|
||||||
|
import { systemAdminGuard } from './guards/system-admin.guard';
|
||||||
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
||||||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||||
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 = [
|
export const routes: Routes = [
|
||||||
{ path: '', component: Home },
|
{ path: '', component: Home },
|
||||||
{ path: 'register', component: Register },
|
{ path: 'register', component: Register, title: 'Cadastro' },
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent, title: 'Login' },
|
||||||
|
|
||||||
{ path: 'geral', component: Geral, canActivate: [authGuard] },
|
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
|
||||||
{ path: 'mureg', component: Mureg, canActivate: [authGuard] },
|
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
|
||||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
|
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard, adminGuard], title: 'Faturamento' },
|
||||||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
|
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
|
||||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
|
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
|
||||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
|
{ 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
|
// ✅ 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
|
// ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard
|
||||||
{ path: 'portal/relatorios', redirectTo: 'relatorios', pathMatch: 'full' },
|
{ path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
|
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { TestBed } from '@angular/core/testing';
|
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';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -14,10 +22,10 @@ describe('App', () => {
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render title', () => {
|
it('should render app layout', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, line-gestao-frontend');
|
expect(compiled.querySelector('main.app-main')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// src/app/app.ts
|
// src/app/app.ts
|
||||||
import { Component, Inject, PLATFORM_ID } from '@angular/core';
|
import { Component, Inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
|
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 { Header } from './components/header/header';
|
||||||
import { FooterComponent } from './components/footer/footer';
|
import { FooterComponent } from './components/footer/footer';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
|
@ -33,11 +34,19 @@ export class AppComponent {
|
||||||
'/dadosusuarios',
|
'/dadosusuarios',
|
||||||
'/vigencia',
|
'/vigencia',
|
||||||
'/trocanumero',
|
'/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(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private authService: AuthService,
|
||||||
@Inject(PLATFORM_ID) private platformId: object
|
@Inject(PLATFORM_ID) private platformId: object
|
||||||
) {
|
) {
|
||||||
this.router.events.subscribe((event) => {
|
this.router.events.subscribe((event) => {
|
||||||
|
|
@ -56,10 +65,30 @@ export class AppComponent {
|
||||||
|
|
||||||
// ✅ footer some ao logar + também no login/register
|
// ✅ footer some ao logar + também no login/register
|
||||||
this.hideFooter = isLoggedRoute || this.isFullScreenPage;
|
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 };
|
export { AppComponent as App };
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { Component, EventEmitter, Output, Input } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cta-button',
|
selector: 'app-cta-button',
|
||||||
|
standalone: true,
|
||||||
templateUrl: './cta-button.html',
|
templateUrl: './cta-button.html',
|
||||||
styleUrls: ['./cta-button.scss']
|
styleUrls: ['./cta-button.scss']
|
||||||
})
|
})
|
||||||
export class CtaButtonComponent {
|
export class CtaButtonComponent {
|
||||||
|
|
||||||
@Input() label: string = 'COMEÇAR AGORA';
|
@Input() label: string = 'COMEÇAR AGORA';
|
||||||
|
|
||||||
@Input() width: string = '250px';
|
@Input() width: string = '250px';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<header class="app-header" [class.scrolled]="isScrolled">
|
<header class="app-header" [class.scrolled]="isScrolled">
|
||||||
<div class="header-inner container">
|
<div class="header-inner container">
|
||||||
|
|
||||||
<!-- ✅ LOGADO: hambúrguer + logo JUNTOS -->
|
|
||||||
<ng-container *ngIf="isLoggedHeader; else publicHeader">
|
<ng-container *ngIf="isLoggedHeader; else publicHeader">
|
||||||
|
<div class="logged-header">
|
||||||
<div class="left-logged">
|
<div class="left-logged">
|
||||||
<button class="btn-icon" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
|
<button class="btn-icon hamburger" type="button" (click)="toggleMenu()" aria-label="Abrir menu">
|
||||||
<i class="bi bi-list"></i>
|
<i class="bi bi-list"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a routerLink="/geral" class="logo-area" (click)="closeMenu()">
|
<a routerLink="/dashboard" class="logo-area" (click)="closeMenu()">
|
||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<i class="bi bi-layers-fill"></i>
|
<i class="bi bi-layers-fill"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -17,25 +17,203 @@
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- ✅ PÚBLICO (HOME): menu + botão -->
|
|
||||||
<ng-template #publicHeader>
|
<ng-template #publicHeader>
|
||||||
<a routerLink="/" class="logo-area">
|
<a routerLink="/" class="logo-area">
|
||||||
<div class="logo-icon">
|
<div class="logo-icon"><i class="bi bi-layers-fill"></i></div>
|
||||||
<i class="bi bi-layers-fill"></i>
|
<div class="logo-text">Line<span class="highlight">Gestão</span></div>
|
||||||
</div>
|
|
||||||
<div class="logo-text">
|
|
||||||
Line<span class="highlight">Gestão</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav-links">
|
<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/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/proposta" target="_blank" class="nav-link">Proposta</a>
|
||||||
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
|
<a href="https://www.linemovel.com.br/sobrenos" target="_blank" class="nav-link">Sobre</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a routerLink="/login" class="btn-login-header">
|
<a routerLink="/login" class="btn-login-header">
|
||||||
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
|
Acessar Sistema <i class="bi bi-arrow-right-short"></i>
|
||||||
|
|
@ -44,62 +222,323 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<!-- ✅ OVERLAY (logado) -->
|
<div class="modal-overlay" *ngIf="createUserOpen" (click)="closeCreateUserModal()"></div>
|
||||||
<div class="menu-overlay" *ngIf="isLoggedHeader && menuOpen" (click)="closeMenu()"></div>
|
<div class="modal-card" *ngIf="createUserOpen" (click)="$event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
<!-- ✅ MENU LATERAL (logado) -->
|
<h3>Novo Usuário LineGestão</h3>
|
||||||
<aside
|
<button type="button" class="btn-icon close-x" (click)="closeCreateUserModal()" aria-label="Fechar">
|
||||||
*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()">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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()">
|
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<i class="bi bi-sim"></i> <span>Geral</span>
|
<i class="bi bi-sim"></i> <span>Geral</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<a routerLink="/mureg" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<i class="bi bi-diagram-3-fill"></i> <span>Mureg</span>
|
||||||
<i class="bi bi-table"></i> <span>Mureg</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
|
||||||
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
||||||
<i class="bi bi-calendar-check"></i> <span>Vigência</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<i class="bi bi-clock-history"></i> <span>Histórico</span>
|
||||||
<i class="bi bi-arrow-left-right"></i> <span>Troca de Número</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
|
||||||
<i class="bi bi-person-lines-fill"></i> <span>Dados dos Usuários</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
<a *ngIf="canViewAll" routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||||
<!-- ✅ CORRIGIDO + ESTILIZADO IGUAL AOS OUTROS -->
|
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
||||||
<a routerLink="/relatorios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
</a>
|
||||||
<i class="bi bi-bar-chart-fill"></i> <span>Relatórios</span>
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,7 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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';
|
import { Header } from './header';
|
||||||
|
|
||||||
|
|
@ -8,7 +11,12 @@ describe('Header', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Header]
|
imports: [Header],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
import { inject, PLATFORM_ID } from '@angular/core';
|
import { inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = () => {
|
export const authGuard: CanActivateFn = () => {
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
const platformId = inject(PLATFORM_ID);
|
const platformId = inject(PLATFORM_ID);
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
|
||||||
// SSR: não existe localStorage. Bloqueia e manda pro login.
|
// SSR: não existe localStorage. Bloqueia e manda pro login.
|
||||||
if (!isPlatformBrowser(platformId)) {
|
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) {
|
if (!token) {
|
||||||
return router.parseUrl('/login');
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { HttpInterceptorFn } from '@angular/common/http';
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
// ✅ SSR-safe
|
// ✅ SSR-safe
|
||||||
if (typeof window === 'undefined') return next(req);
|
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);
|
if (!token) return next(req);
|
||||||
|
|
||||||
return next(
|
return next(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,16 +22,19 @@
|
||||||
<div class="geral-header">
|
<div class="geral-header">
|
||||||
<div class="header-row-top">
|
<div class="header-row-top">
|
||||||
<div class="title-badge" data-animate>
|
<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>
|
||||||
<div class="header-title" data-animate>
|
<div class="header-title" data-animate>
|
||||||
<h5 class="title mb-0">GESTÃO DE USUÁRIOS</h5>
|
<h5 class="title mb-0">GESTÃO DE USUÁRIOS PF/PJ</h5>
|
||||||
<small class="subtitle">Base de dados agrupada por cliente</small>
|
<small class="subtitle">Base de dados separada por pessoa física e jurídica</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
|
<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">
|
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
|
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
|
||||||
</button>
|
</button>
|
||||||
|
<button *ngIf="isAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -51,10 +54,10 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi">
|
<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 class="val text-success">
|
||||||
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi">
|
<div class="kpi">
|
||||||
|
|
@ -67,21 +70,25 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls mt-3 mb-2" data-animate>
|
<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">
|
<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>
|
<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>
|
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearFilters()" *ngIf="search"><i class="bi bi-x-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-size d-flex align-items-center gap-2">
|
<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>
|
<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">
|
<div class="select-wrapper">
|
||||||
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
|
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,7 +110,8 @@
|
||||||
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
|
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
|
||||||
<div class="group-badges">
|
<div class="group-badges">
|
||||||
<span class="badge-pill total">{{ g.totalRegistros }} Registros</span>
|
<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>
|
<span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,10 +134,10 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>ITEM</th>
|
<th>ITEM</th>
|
||||||
<th>LINHA</th>
|
<th>LINHA</th>
|
||||||
<th>CPF</th>
|
<th>{{ tipoFilter === 'PJ' ? 'CNPJ' : 'CPF' }}</th>
|
||||||
<th>E-MAIL</th>
|
<th>E-MAIL</th>
|
||||||
<th>CELULAR</th>
|
<th>CELULAR</th>
|
||||||
<th style="min-width: 80px;">AÇÕES</th>
|
<th class="actions-col">AÇÕES</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -139,12 +147,14 @@
|
||||||
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
|
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
|
||||||
<td class="text-muted fw-bold">{{ r.item }}</td>
|
<td class="text-muted fw-bold">{{ r.item }}</td>
|
||||||
<td class="fw-black text-blue">{{ r.linha }}</td>
|
<td class="fw-black text-blue">{{ 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 td-clip" [title]="r.email">{{ r.email || '-' }}</td>
|
||||||
<td class="text-muted small">{{ r.celular || '-' }}</td>
|
<td class="text-muted small">{{ r.celular || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-group justify-content-center">
|
<div class="action-group justify-content-center">
|
||||||
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
|
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -171,9 +181,9 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="modal-backdrop-custom" *ngIf="detailsOpen" (click)="closeDetails()"></div>
|
<div class="modal-backdrop-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
|
||||||
<div class="modal-custom" *ngIf="detailsOpen">
|
<div class="modal-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()">
|
||||||
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
<div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<div class="modal-title">
|
||||||
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
|
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
|
||||||
|
|
@ -188,10 +198,11 @@
|
||||||
<div class="box-body">
|
<div class="box-body">
|
||||||
<div class="form-grid">
|
<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 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>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>ITEM</label><div>{{ selectedRow?.item }}</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>CPF</label><div>{{ selectedRow?.cpf || '-' }}</div></div>
|
|
||||||
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</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>
|
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
|
||||||
|
|
@ -206,4 +217,223 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,22 @@
|
||||||
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
.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; }
|
.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; }
|
.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 */
|
/* Buttons */
|
||||||
.btn-brand {
|
.btn-brand {
|
||||||
|
|
@ -177,6 +192,74 @@
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
.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 {
|
.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;
|
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); }
|
&: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; }
|
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
|
||||||
.empty-state { background: rgba(255,255,255,0.4); }
|
.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 {
|
.btn-icon {
|
||||||
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
|
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer;
|
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); }
|
&: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); }
|
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
|
||||||
|
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FOOTER */
|
/* FOOTER */
|
||||||
|
|
@ -256,17 +351,338 @@
|
||||||
|
|
||||||
/* MODALS */
|
/* MODALS */
|
||||||
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
|
.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-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; }
|
.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); } }
|
@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-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; &.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; }
|
||||||
|
|
||||||
|
.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 */
|
/* FORM & DETAILS */
|
||||||
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
.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.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; }
|
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; } }
|
.edit-sections { display: grid; gap: 12px; }
|
||||||
.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 .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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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';
|
import { DadosUsuarios } from './dados-usuarios';
|
||||||
|
|
||||||
describe('DadosUsuarios', () => {
|
describe('DadosUsuarios', () => {
|
||||||
|
|
@ -8,6 +11,11 @@ describe('DadosUsuarios', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [DadosUsuarios], // standalone component
|
imports: [DadosUsuarios], // standalone component
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DadosUsuarios);
|
fixture = TestBed.createComponent(DadosUsuarios);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,43 @@
|
||||||
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
|
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 {
|
import {
|
||||||
DadosUsuariosService,
|
DadosUsuariosService,
|
||||||
UserDataClientGroup,
|
UserDataClientGroup,
|
||||||
UserDataRow,
|
UserDataRow,
|
||||||
UserDataGroupResponse,
|
UserDataGroupResponse,
|
||||||
PagedResult
|
PagedResult,
|
||||||
|
UpdateUserDataRequest,
|
||||||
|
CreateUserDataRequest
|
||||||
} from '../../services/dados-usuarios.service';
|
} 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';
|
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({
|
@Component({
|
||||||
selector: 'app-dados-usuarios',
|
selector: 'app-dados-usuarios',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, HttpClientModule],
|
imports: [CommonModule, FormsModule, CustomSelectComponent],
|
||||||
templateUrl: './dados-usuarios.html',
|
templateUrl: './dados-usuarios.html',
|
||||||
styleUrls: ['./dados-usuarios.scss'],
|
styleUrls: ['./dados-usuarios.scss']
|
||||||
providers: [DadosUsuariosService]
|
|
||||||
})
|
})
|
||||||
export class DadosUsuarios implements OnInit {
|
export class DadosUsuarios implements OnInit {
|
||||||
|
|
||||||
|
|
@ -30,10 +48,12 @@ export class DadosUsuarios implements OnInit {
|
||||||
|
|
||||||
// Filtros
|
// Filtros
|
||||||
search = '';
|
search = '';
|
||||||
|
tipoFilter: 'PF' | 'PJ' = 'PF';
|
||||||
|
|
||||||
// Paginação
|
// Paginação
|
||||||
page = 1;
|
page = 1;
|
||||||
pageSize = 10;
|
pageSize = 10;
|
||||||
|
pageSizeOptions = [10, 20, 50, 100];
|
||||||
total = 0;
|
total = 0;
|
||||||
|
|
||||||
// Ordenação
|
// Ordenação
|
||||||
|
|
@ -51,6 +71,7 @@ export class DadosUsuarios implements OnInit {
|
||||||
kpiTotalRegistros = 0;
|
kpiTotalRegistros = 0;
|
||||||
kpiClientesUnicos = 0;
|
kpiClientesUnicos = 0;
|
||||||
kpiComCpf = 0;
|
kpiComCpf = 0;
|
||||||
|
kpiComCnpj = 0;
|
||||||
kpiComEmail = 0;
|
kpiComEmail = 0;
|
||||||
|
|
||||||
// ACORDEÃO
|
// ACORDEÃO
|
||||||
|
|
@ -61,15 +82,42 @@ export class DadosUsuarios implements OnInit {
|
||||||
// Modal / Toast
|
// Modal / Toast
|
||||||
detailsOpen = false;
|
detailsOpen = false;
|
||||||
selectedRow: UserDataRow | null = null;
|
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;
|
toastOpen = false;
|
||||||
toastMessage = '';
|
toastMessage = '';
|
||||||
toastType: 'success' | 'danger' = 'success';
|
toastType: 'success' | 'danger' = 'success';
|
||||||
private toastTimer: any = null;
|
private toastTimer: any = null;
|
||||||
private searchTimer: any = null;
|
private searchTimer: any = null;
|
||||||
|
|
||||||
constructor(private service: DadosUsuariosService) {}
|
constructor(
|
||||||
|
private service: DadosUsuariosService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private linesService: LinesService
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
this.fetch(1);
|
this.fetch(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +175,7 @@ export class DadosUsuarios implements OnInit {
|
||||||
private fetchGroups() {
|
private fetchGroups() {
|
||||||
this.service.getGroups({
|
this.service.getGroups({
|
||||||
search: this.search?.trim(),
|
search: this.search?.trim(),
|
||||||
|
tipo: this.tipoFilter,
|
||||||
page: this.page,
|
page: this.page,
|
||||||
pageSize: this.pageSize,
|
pageSize: this.pageSize,
|
||||||
sortBy: this.sortBy,
|
sortBy: this.sortBy,
|
||||||
|
|
@ -139,6 +188,7 @@ export class DadosUsuarios implements OnInit {
|
||||||
this.kpiTotalRegistros = res.kpis.totalRegistros;
|
this.kpiTotalRegistros = res.kpis.totalRegistros;
|
||||||
this.kpiClientesUnicos = res.kpis.clientesUnicos;
|
this.kpiClientesUnicos = res.kpis.clientesUnicos;
|
||||||
this.kpiComCpf = res.kpis.comCpf;
|
this.kpiComCpf = res.kpis.comCpf;
|
||||||
|
this.kpiComCnpj = res.kpis.comCnpj;
|
||||||
this.kpiComEmail = res.kpis.comEmail;
|
this.kpiComEmail = res.kpis.comEmail;
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
@ -167,6 +217,7 @@ export class DadosUsuarios implements OnInit {
|
||||||
|
|
||||||
this.service.getRows({
|
this.service.getRows({
|
||||||
client: g.cliente,
|
client: g.cliente,
|
||||||
|
tipo: this.tipoFilter,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
sortBy: 'item',
|
sortBy: 'item',
|
||||||
|
|
@ -192,6 +243,15 @@ export class DadosUsuarios implements OnInit {
|
||||||
}, 400);
|
}, 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); }
|
clearFilters() { this.search = ''; this.fetch(1); }
|
||||||
|
|
||||||
onPageSizeChange() {
|
onPageSizeChange() {
|
||||||
|
|
@ -207,7 +267,13 @@ export class DadosUsuarios implements OnInit {
|
||||||
openDetails(row: UserDataRow) {
|
openDetails(row: UserDataRow) {
|
||||||
this.service.getById(row.id).subscribe({
|
this.service.getById(row.id).subscribe({
|
||||||
next: (fullData: UserDataRow) => {
|
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;
|
this.detailsOpen = true;
|
||||||
},
|
},
|
||||||
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
|
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
|
||||||
|
|
@ -216,9 +282,316 @@ export class DadosUsuarios implements OnInit {
|
||||||
|
|
||||||
closeDetails() { this.detailsOpen = false; }
|
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; }
|
trackById(_: number, row: UserDataRow) { return row.id; }
|
||||||
trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; }
|
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') {
|
showToast(msg: string, type: 'success' | 'danger') {
|
||||||
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
|
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
|
||||||
if(this.toastTimer) clearTimeout(this.toastTimer);
|
if(this.toastTimer) clearTimeout(this.toastTimer);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
|
@ -115,23 +115,23 @@
|
||||||
|
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="fat-kpis mt-4 animate-fade-in">
|
<div class="fat-kpis mt-4 animate-fade-in">
|
||||||
<div class="kpi">
|
<div class="kpi kpi-stack kpi-stack-tight">
|
||||||
<span class="lbl">Total Clientes</span>
|
<span class="lbl">Clientes Faturados</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||||
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
|
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpi">
|
<div class="kpi kpi-stack kpi-stack-tight">
|
||||||
<span class="lbl">Total Linhas</span>
|
<span class="lbl">Linhas Faturadas</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||||
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
|
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpi kpi-wide">
|
<div class="kpi kpi-wide kpi-stack">
|
||||||
<span class="lbl text-vivo">Total Vivo</span>
|
<span class="lbl text-vivo">Total Vivo</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpi kpi-wide">
|
<div class="kpi kpi-wide kpi-stack">
|
||||||
<span class="lbl text-line">Total Line</span>
|
<span class="lbl text-line">Total Line</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpi">
|
<div class="kpi kpi-stack kpi-stack-tight">
|
||||||
<span class="lbl text-brand">Lucro</span>
|
<span class="lbl text-brand">Lucro</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||||
|
|
@ -168,7 +168,7 @@
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Pesquisar por cliente, aparelho, forma de pagamento..."
|
placeholder="Pesquisar..."
|
||||||
[(ngModel)]="searchTerm"
|
[(ngModel)]="searchTerm"
|
||||||
(ngModelChange)="onSearch()" />
|
(ngModelChange)="onSearch()" />
|
||||||
|
|
||||||
|
|
@ -183,13 +183,8 @@
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
|
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +224,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="group-body" *ngIf="expandedGroup === g.cliente">
|
<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>
|
<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>
|
<span class="chip-muted"><i class="bi bi-info-circle me-1"></i> Clique no “olho” para ver todos os detalhes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,35 +233,35 @@
|
||||||
<table class="table table-modern table-compact align-middle text-center mb-0">
|
<table class="table table-modern table-compact align-middle text-center mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="thead-group">
|
<tr class="thead-group">
|
||||||
<th rowspan="2" class="sortable" (click)="setSort('item')">
|
<th rowspan="2" class="sortable th-item" (click)="setSort('item')">
|
||||||
<div class="th-content">ITEM <span class="sort-caret" [class.active]="sortBy==='item'">{{ sortBy==='item' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
|
<div class="th-content">ITEM</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th rowspan="2" class="sortable" (click)="setSort('qtdlinhas')">
|
<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>
|
||||||
|
|
||||||
<th colspan="2" class="th-block th-vivo">VIVO</th>
|
<th colspan="2" class="th-block th-vivo">VIVO</th>
|
||||||
<th colspan="2" class="th-block th-line">LINE MÓVEL</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>
|
||||||
|
|
||||||
<tr class="thead-sub">
|
<tr class="thead-sub">
|
||||||
<th class="sortable" (click)="setSort('franquiavivo')">
|
<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>
|
||||||
|
|
||||||
<th class="sortable" (click)="setSort('valorcontratovivo')">
|
<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>
|
||||||
|
|
||||||
<th class="sortable" (click)="setSort('franquialine')">
|
<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>
|
||||||
|
|
||||||
<th class="sortable" (click)="setSort('valorcontratoline')">
|
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -291,6 +286,8 @@
|
||||||
<div class="action-group justify-content-center">
|
<div class="action-group justify-content-center">
|
||||||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
<button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
|
<button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||||
|
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -330,15 +327,15 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- MODAIS -->
|
<!-- 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 -->
|
<!-- DETAIL MODAL -->
|
||||||
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
|
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<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
|
Detalhes do Faturamento
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
|
<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="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="mb-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<div class="fw-black" style="font-size: 1.05rem;">
|
<div class="fw-black detail-client">
|
||||||
{{ detailData.cliente || '—' }}
|
{{ detailData.cliente || '—' }}
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted fw-bold">
|
<small class="text-muted fw-bold">
|
||||||
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
|
ITEM: {{ detailData.item }} • QTD LINHAS: {{ detailData.qtdLinhas ?? 0 }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="details-dashboard details-2col">
|
<div class="details-dashboard details-single">
|
||||||
|
|
||||||
<!-- IDENTIFICAÇÃO -->
|
<!-- IDENTIFICAÇÃO -->
|
||||||
<div class="detail-box">
|
<div class="detail-box">
|
||||||
|
|
@ -400,77 +389,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<ng-template #detailLoading>
|
<ng-template #detailLoading>
|
||||||
|
|
@ -482,7 +402,7 @@
|
||||||
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
<div *ngIf="compareOpen" #compareModal class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<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>
|
</div>
|
||||||
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
|
<button class="btn btn-sm btn-icon" (click)="closeAllModals()"><i class="bi bi-x-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -528,4 +448,127 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</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 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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
|
|
||||||
.container-fat {
|
.container-fat {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1180px;
|
max-width: 1240px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: var(--page-top-gap);
|
margin-top: var(--page-top-gap);
|
||||||
|
|
@ -97,8 +97,9 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: calc(100vh - 18px) !important;
|
height: auto !important;
|
||||||
min-height: 0;
|
min-height: 80vh;
|
||||||
|
max-height: none !important;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
@ -173,6 +174,22 @@
|
||||||
font-weight: 700;
|
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 */
|
||||||
.filters-row {
|
.filters-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -321,7 +338,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-group {
|
.search-group {
|
||||||
max-width: 360px;
|
max-width: 270px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
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 {
|
.kpi-wide {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
|
|
@ -471,6 +522,8 @@
|
||||||
.groups-container {
|
.groups-container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,6 +605,16 @@
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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 {
|
.chip-muted {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -565,7 +628,11 @@
|
||||||
border: 1px solid rgba(17,18,20,0.06);
|
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 */
|
||||||
.table-wrap { overflow: auto; height: 100%; }
|
.table-wrap { overflow: auto; height: 100%; }
|
||||||
|
|
@ -591,6 +658,9 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover { color: var(--brand); }
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
|
|
@ -614,9 +684,19 @@
|
||||||
.sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } }
|
.sort-caret { width: 14px; opacity: 0.3; &.active { opacity: 1; color: var(--brand); } }
|
||||||
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
|
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
|
||||||
.empty-state { background: rgba(255,255,255,0.4); }
|
.empty-state { background: rgba(255,255,255,0.4); }
|
||||||
|
.th-item .th-content { justify-content: center; }
|
||||||
|
|
||||||
/* ACTIONS */
|
/* 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 {
|
.btn-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|
@ -632,6 +712,8 @@
|
||||||
|
|
||||||
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
|
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
|
||||||
&.success:hover { color: var(--success-text); background: var(--success-bg); }
|
&.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 */
|
/* FOOTER */
|
||||||
|
|
@ -698,8 +780,9 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: min(900px, 100%);
|
width: min(850px, 100%);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
min-height: 0;
|
||||||
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -734,17 +817,140 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
&.success { background: var(--success-bg); color: var(--success-text); }
|
&.success { background: var(--success-bg); color: var(--success-text); }
|
||||||
&.primary-soft { 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; }
|
||||||
|
&.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); } }
|
.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; }
|
.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) */
|
/* detalhes e comparativo (mantidos) */
|
||||||
.details-dashboard {
|
.details-dashboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -756,6 +962,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
|
.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 {
|
.detail-box {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -765,10 +977,22 @@
|
||||||
overflow: hidden;
|
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 {
|
.box-header.justify-content-center {
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
text-align: center;
|
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);
|
color: var(--brand);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
|
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
|
|
@ -19,8 +20,11 @@ import {
|
||||||
BillingSortBy,
|
BillingSortBy,
|
||||||
SortDir,
|
SortDir,
|
||||||
TipoCliente,
|
TipoCliente,
|
||||||
TipoFiltro
|
TipoFiltro,
|
||||||
|
BillingUpdateRequest
|
||||||
} from '../../services/billing';
|
} from '../../services/billing';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
||||||
|
|
||||||
interface BillingClientGroup {
|
interface BillingClientGroup {
|
||||||
cliente: string;
|
cliente: string;
|
||||||
|
|
@ -33,7 +37,7 @@ interface BillingClientGroup {
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, HttpClientModule],
|
imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent],
|
||||||
templateUrl: './faturamento.html',
|
templateUrl: './faturamento.html',
|
||||||
styleUrls: ['./faturamento.scss']
|
styleUrls: ['./faturamento.scss']
|
||||||
})
|
})
|
||||||
|
|
@ -47,7 +51,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(PLATFORM_ID) private platformId: object,
|
@Inject(PLATFORM_ID) private platformId: object,
|
||||||
private billing: BillingService,
|
private billing: BillingService,
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef,
|
||||||
|
private authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
@ -68,6 +73,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
// pagina por CLIENTES (grupos)
|
// pagina por CLIENTES (grupos)
|
||||||
page = 1;
|
page = 1;
|
||||||
pageSize = 10;
|
pageSize = 10;
|
||||||
|
pageSizeOptions = [10, 20, 50, 100];
|
||||||
total = 0; // total de grupos
|
total = 0; // total de grupos
|
||||||
|
|
||||||
// agrupamento
|
// agrupamento
|
||||||
|
|
@ -90,6 +96,14 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
compareOpen = false;
|
compareOpen = false;
|
||||||
detailData: BillingItem | null = null;
|
detailData: BillingItem | null = null;
|
||||||
compareData: 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;
|
private searchTimer: any = null;
|
||||||
|
|
||||||
|
|
@ -118,20 +132,21 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
onDocumentKeydown(ev: KeyboardEvent) {
|
onDocumentKeydown(ev: Event) {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
if (ev.key === 'Escape') {
|
const keyboard = ev as KeyboardEvent;
|
||||||
|
if (keyboard.key === 'Escape') {
|
||||||
if (this.anyModalOpen()) {
|
if (this.anyModalOpen()) {
|
||||||
ev.preventDefault();
|
keyboard.preventDefault();
|
||||||
ev.stopPropagation();
|
keyboard.stopPropagation();
|
||||||
this.closeAllModals();
|
this.closeAllModals();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showClientMenu) {
|
if (this.showClientMenu) {
|
||||||
this.showClientMenu = false;
|
this.showClientMenu = false;
|
||||||
ev.stopPropagation();
|
keyboard.stopPropagation();
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +160,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
if (!isPlatformBrowser(this.platformId)) return;
|
if (!isPlatformBrowser(this.platformId)) return;
|
||||||
|
|
||||||
this.initAnimations();
|
this.initAnimations();
|
||||||
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.refreshData(true);
|
this.refreshData(true);
|
||||||
|
|
@ -163,7 +179,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
// Helpers
|
// Helpers
|
||||||
// --------------------------
|
// --------------------------
|
||||||
private anyModalOpen(): boolean {
|
private anyModalOpen(): boolean {
|
||||||
return !!(this.detailOpen || this.compareOpen);
|
return !!(this.detailOpen || this.compareOpen || this.editOpen || this.deleteOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAllModals() {
|
closeAllModals() {
|
||||||
|
|
@ -171,6 +187,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
this.compareOpen = false;
|
this.compareOpen = false;
|
||||||
this.detailData = null;
|
this.detailData = null;
|
||||||
this.compareData = null;
|
this.compareData = null;
|
||||||
|
this.editOpen = false;
|
||||||
|
this.editModel = null;
|
||||||
|
this.editingId = null;
|
||||||
|
this.deleteOpen = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +223,24 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
.replace(/[\u0300-\u036f]/g, '');
|
.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 {
|
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
|
||||||
if (filtro === 'ALL') return true;
|
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)));
|
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const term = (this.searchTerm ?? '').trim().toLowerCase();
|
const term = this.normalizeText(this.searchTerm);
|
||||||
if (term) {
|
if (term) {
|
||||||
arr = arr.filter((r) => {
|
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KPIs
|
// KPIs
|
||||||
|
|
@ -489,15 +523,35 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
let totalVivo = 0;
|
let totalVivo = 0;
|
||||||
let totalLine = 0;
|
let totalLine = 0;
|
||||||
let totalLucro = 0;
|
let totalLucro = 0;
|
||||||
|
const clientTotals = new Map<string, { vivo: number; line: number; lucro: number }>();
|
||||||
|
|
||||||
for (const r of arr) {
|
for (const r of arr) {
|
||||||
const c = (r.cliente ?? '').trim();
|
const c = (r.cliente ?? '').trim();
|
||||||
if (c) unique.add(c);
|
if (c) unique.add(c);
|
||||||
|
|
||||||
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
|
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
|
||||||
totalVivo += Number(r.valorContratoVivo ?? 0) || 0;
|
|
||||||
totalLine += Number(r.valorContratoLine ?? 0) || 0;
|
const key = this.normalizeText(c);
|
||||||
totalLucro += Number((r as any).lucro ?? 0) || 0;
|
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;
|
this.kpiTotalClientes = unique.size;
|
||||||
|
|
@ -595,13 +649,105 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
// --------------------------
|
// --------------------------
|
||||||
onDetalhes(r: BillingItem) {
|
onDetalhes(r: BillingItem) {
|
||||||
this.detailOpen = true;
|
this.detailOpen = true;
|
||||||
|
this.detailData = null;
|
||||||
|
this.billing.getById(r.id).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.detailData = data ?? r;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
this.detailData = r;
|
this.detailData = r;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onComparativo(r: BillingItem) {
|
onComparativo(r: BillingItem) {
|
||||||
this.compareOpen = true;
|
this.compareOpen = true;
|
||||||
this.compareData = r;
|
this.compareData = r;
|
||||||
this.cdr.detectChanges();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
|
@ -41,13 +41,13 @@
|
||||||
/* 2. LAYOUT DA PÁGINA (Vertical Destravado) */
|
/* 2. LAYOUT DA PÁGINA (Vertical Destravado) */
|
||||||
/* ========================================================== */
|
/* ========================================================== */
|
||||||
.geral-page {
|
.geral-page {
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
padding: 0 12px var(--page-bottom-gap);
|
padding: var(--page-top-gap) 12px var(--page-bottom-gap);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto; /* Scroll na janela */
|
overflow: visible;
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
|
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%),
|
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
max-width: 1100px; /* Largura controlada */
|
max-width: 1100px; /* Largura controlada */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: var(--page-top-gap);
|
margin-top: 0;
|
||||||
margin-bottom: var(--page-bottom-gap);
|
margin-bottom: var(--page-bottom-gap);
|
||||||
margin-left: auto; margin-right: auto;
|
margin-left: auto; margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -138,9 +138,123 @@
|
||||||
.dropdown-list { overflow-y: auto; max-height: 300px; }
|
.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; } }
|
.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 */
|
/* KPIs */
|
||||||
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
|
.geral-kpis { 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 { 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 */
|
||||||
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
.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-info { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.group-badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
.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); } }
|
.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; }
|
.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); }
|
.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); }
|
.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); } }
|
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
/* Inner Table Destravada */
|
/* Inner Table Destravada */
|
||||||
|
|
@ -207,7 +374,10 @@
|
||||||
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { 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; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||||
|
.modal-body .box-body { overflow: visible; }
|
||||||
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
||||||
|
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
|
||||||
|
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
|
||||||
|
|
||||||
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
|
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
|
||||||
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
||||||
|
|
@ -231,8 +401,8 @@
|
||||||
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
|
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Caixas de Detalhes e Accordions simples */
|
/* 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; } }
|
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: hidden; height: 100%; display: flex; flex-direction: column; }
|
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); } }
|
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); }
|
details[open] summary .transition-icon { transform: rotate(180deg); color: var(--brand); }
|
||||||
|
|
@ -260,3 +430,81 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } }
|
.form-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-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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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';
|
import { Geral } from './geral';
|
||||||
|
|
||||||
|
|
@ -8,7 +11,12 @@ describe('Geral', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Geral]
|
imports: [Geral],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
|
|
@ -20,4 +28,49 @@ describe('Geral', () => {
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
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
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { Component, AfterViewInit, Inject, PLATFORM_ID } from '@angular/core';
|
import { Component, AfterViewInit, Inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
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';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FeatureCardComponent, CtaButtonComponent],
|
imports: [CommonModule],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrls: ['./home.scss'],
|
styleUrls: ['./home.scss'],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
Lembrar de mim
|
Lembrar de mim
|
||||||
</label>
|
</label>
|
||||||
<a href="#" class="forgot-link">Esqueceu a senha?</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">
|
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
onSubmit(): void {
|
||||||
console.log('🚀 Iniciando login...');
|
console.log('🚀 Iniciando login...');
|
||||||
this.apiError = '';
|
this.apiError = '';
|
||||||
|
|
@ -94,7 +82,10 @@ export class LoginComponent {
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
const v = this.loginForm.value;
|
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
|
next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
|
||||||
console.log('✅ Resposta da API:', res);
|
console.log('✅ Resposta da API:', res);
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
|
|
@ -109,26 +100,34 @@ export class LoginComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔑 Token encontrado. Salvando...');
|
this.authService.setToken(token, !!v.rememberMe);
|
||||||
this.saveToken(token);
|
|
||||||
|
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
|
// VERIFICAÇÃO 2: Decodificação
|
||||||
try {
|
try {
|
||||||
const nome = this.getNameFromToken(token);
|
const nome = this.getNameFromToken(token);
|
||||||
console.log('👤 Nome extraído:', nome);
|
console.log('👤 Nome extraído:', nome);
|
||||||
|
|
||||||
console.log('🔄 Tentando ir para /geral...');
|
console.log('🔄 Tentando ir para /dashboard...');
|
||||||
this.router.navigate(['/geral'], {
|
this.router.navigate(['/dashboard'], {
|
||||||
state: { toastMessage: `Bem-vindo, ${nome}!` }
|
state: { toastMessage: `Bem-vindo, ${nome}!` }
|
||||||
}).then(sucesso => {
|
}).then(sucesso => {
|
||||||
if (sucesso) console.log('✅ Navegação funcionou!');
|
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) {
|
} catch (e) {
|
||||||
console.error('❌ Erro ao processar token ou navegar:', e);
|
console.error('❌ Erro ao processar token ou navegar:', e);
|
||||||
// Força a ida mesmo se o nome falhar
|
// Força a ida mesmo se o nome falhar
|
||||||
this.router.navigate(['/geral']);
|
this.router.navigate(['/dashboard']);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
|
@ -146,3 +145,5 @@ export class LoginComponent {
|
||||||
return !!(control.touched && control.invalid);
|
return !!(control.touched && control.invalid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,18 +88,7 @@
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select
|
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,9 +170,15 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-group justify-content-center">
|
<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">
|
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar Registro">
|
||||||
<i class="bi bi-pencil-square"></i>
|
<i class="bi bi-pencil-square"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-icon danger" (click)="onDelete(r)" title="Excluir Registro">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -223,7 +218,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- EDIT MODAL -->
|
||||||
|
|
@ -264,14 +259,7 @@
|
||||||
<!-- Cliente (select) -->
|
<!-- Cliente (select) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Cliente (GERAL)</label>
|
<label>Cliente (GERAL)</label>
|
||||||
<select
|
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="editModel.selectedClient" (ngModelChange)="onEditClientChange()" placeholder="Selecione..."></app-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>
|
|
||||||
|
|
||||||
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
|
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
|
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
|
||||||
|
|
@ -281,19 +269,7 @@
|
||||||
<!-- Linha Antiga (select da Geral) -->
|
<!-- Linha Antiga (select da Geral) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Linha Antiga (GERAL)</label>
|
<label>Linha Antiga (GERAL)</label>
|
||||||
<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>
|
||||||
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>
|
|
||||||
|
|
||||||
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
|
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
|
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
|
||||||
|
|
@ -388,14 +364,7 @@
|
||||||
<!-- Cliente (select) -->
|
<!-- Cliente (select) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
|
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
|
||||||
<select
|
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="createModel.selectedClient" (ngModelChange)="onCreateClientChange()" placeholder="Selecione..."></app-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>
|
|
||||||
|
|
||||||
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
|
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
|
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
|
||||||
|
|
@ -405,19 +374,7 @@
|
||||||
<!-- Linha Antiga (select Geral) -->
|
<!-- Linha Antiga (select Geral) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
|
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
|
||||||
<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>
|
||||||
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>
|
|
||||||
|
|
||||||
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
|
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
|
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
|
||||||
|
|
@ -467,3 +424,110 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,8 @@
|
||||||
color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer;
|
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); }
|
&: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); }
|
&.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 */
|
/* 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-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-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 { 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); } }
|
@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; }
|
.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;
|
.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); }
|
&.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 */
|
&.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); } }
|
.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 */
|
/* FORM & DETAILS */
|
||||||
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
.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-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; }
|
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 */
|
/* EDIT FORM STYLES */
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ import {
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 { 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';
|
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
|
||||||
|
|
||||||
|
|
@ -50,6 +53,7 @@ interface LineOptionDto {
|
||||||
usuario: string | null;
|
usuario: string | null;
|
||||||
cliente?: string | null;
|
cliente?: string | null;
|
||||||
skil?: string | null;
|
skil?: string | null;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MuregDetailDto {
|
interface MuregDetailDto {
|
||||||
|
|
@ -73,7 +77,7 @@ interface MuregDetailDto {
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, HttpClientModule],
|
imports: [CommonModule, FormsModule, CustomSelectComponent],
|
||||||
templateUrl: './mureg.html',
|
templateUrl: './mureg.html',
|
||||||
styleUrls: ['./mureg.scss']
|
styleUrls: ['./mureg.scss']
|
||||||
})
|
})
|
||||||
|
|
@ -90,7 +94,11 @@ export class Mureg implements AfterViewInit {
|
||||||
private linesService: LinesService
|
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 ======
|
// ====== DATA ======
|
||||||
clientGroups: ClientGroup[] = [];
|
clientGroups: ClientGroup[] = [];
|
||||||
|
|
@ -109,6 +117,7 @@ export class Mureg implements AfterViewInit {
|
||||||
private searchTimer: any = null;
|
private searchTimer: any = null;
|
||||||
page = 1;
|
page = 1;
|
||||||
pageSize = 10;
|
pageSize = 10;
|
||||||
|
pageSizeOptions = [10, 20, 50, 100];
|
||||||
total = 0;
|
total = 0;
|
||||||
|
|
||||||
// ====== OPTIONS (GERAL) ======
|
// ====== OPTIONS (GERAL) ======
|
||||||
|
|
@ -129,6 +138,16 @@ export class Mureg implements AfterViewInit {
|
||||||
editSaving = false;
|
editSaving = false;
|
||||||
editModel: any = null;
|
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 ======
|
// ====== CREATE MODAL ======
|
||||||
createOpen = false;
|
createOpen = false;
|
||||||
createSaving = false;
|
createSaving = false;
|
||||||
|
|
@ -401,7 +420,8 @@ export class Mureg implements AfterViewInit {
|
||||||
chip: x.chip ?? null,
|
chip: x.chip ?? null,
|
||||||
usuario: x.usuario ?? null,
|
usuario: x.usuario ?? null,
|
||||||
cliente: x.cliente ?? 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());
|
.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
|
// Helpers
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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[] = [];
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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';
|
import { Register } from './register';
|
||||||
|
|
||||||
|
|
@ -8,7 +11,12 @@ describe('Register', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Register]
|
imports: [Register],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export class Register {
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
|
|
||||||
// Se você não quer manter "logado" após cadastrar:
|
// 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.');
|
await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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', '0–30 dias', '31–60 dias', '61–90 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
|
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
|
||||||
</span>
|
</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">
|
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -85,13 +85,8 @@
|
||||||
<div class="page-size d-flex align-items-center gap-2">
|
<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>
|
<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">
|
<div class="select-wrapper">
|
||||||
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
|
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -320,13 +315,7 @@
|
||||||
<!-- ✅ Cliente (GERAL) -->
|
<!-- ✅ Cliente (GERAL) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Cliente (GERAL)</label>
|
<label>Cliente (GERAL)</label>
|
||||||
<select class="form-control form-control-sm"
|
<app-select class="form-control" size="sm" [options]="clientsFromGeral" [(ngModel)]="selectedCliente" (ngModelChange)="onClienteChange()" placeholder="Selecione..."></app-select>
|
||||||
[(ngModel)]="selectedCliente"
|
|
||||||
(change)="onClienteChange()"
|
|
||||||
[disabled]="loadingClients">
|
|
||||||
<option value="">Selecione...</option>
|
|
||||||
<option *ngFor="let c of clientsFromGeral" [value]="c">{{ c }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<small class="hint" *ngIf="loadingClients">
|
<small class="hint" *ngIf="loadingClients">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
|
<span class="spinner-border spinner-border-sm me-2"></span> Carregando clientes...
|
||||||
|
|
@ -336,15 +325,7 @@
|
||||||
<!-- ✅ Linha do Cliente (GERAL) -->
|
<!-- ✅ Linha do Cliente (GERAL) -->
|
||||||
<div class="form-field span-2">
|
<div class="form-field span-2">
|
||||||
<label>Linha do Cliente (GERAL)</label>
|
<label>Linha do Cliente (GERAL)</label>
|
||||||
<select class="form-control form-control-sm"
|
<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>
|
||||||
[(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>
|
|
||||||
|
|
||||||
<small class="hint" *ngIf="loadingLines">
|
<small class="hint" *ngIf="loadingLines">
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
|
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
|
||||||
|
|
@ -392,3 +373,5 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@
|
||||||
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||||
|
|
||||||
.search-group {
|
.search-group {
|
||||||
max-width: 380px;
|
max-width: 270px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import {
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
import { isPlatformBrowser, CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
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';
|
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
|
||||||
|
|
||||||
|
|
@ -49,11 +51,12 @@ interface LineOptionDto {
|
||||||
cliente: string | null;
|
cliente: string | null;
|
||||||
usuario: string | null;
|
usuario: string | null;
|
||||||
skil: string | null;
|
skil: string | null;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, HttpClientModule],
|
imports: [CommonModule, FormsModule, CustomSelectComponent],
|
||||||
templateUrl: './troca-numero.html',
|
templateUrl: './troca-numero.html',
|
||||||
styleUrls: ['./troca-numero.scss']
|
styleUrls: ['./troca-numero.scss']
|
||||||
})
|
})
|
||||||
|
|
@ -69,10 +72,18 @@ export class TrocaNumero implements AfterViewInit {
|
||||||
private cdr: ChangeDetectorRef
|
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) */
|
/** ✅ 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 ======
|
// ====== DATA ======
|
||||||
groups: GroupItem[] = [];
|
groups: GroupItem[] = [];
|
||||||
|
|
@ -92,6 +103,7 @@ export class TrocaNumero implements AfterViewInit {
|
||||||
private searchTimer: any = null;
|
private searchTimer: any = null;
|
||||||
page = 1;
|
page = 1;
|
||||||
pageSize = 10;
|
pageSize = 10;
|
||||||
|
pageSizeOptions = [10, 20, 50, 100];
|
||||||
total = 0;
|
total = 0;
|
||||||
|
|
||||||
// ====== EDIT MODAL ======
|
// ====== EDIT MODAL ======
|
||||||
|
|
@ -357,7 +369,10 @@ export class TrocaNumero implements AfterViewInit {
|
||||||
|
|
||||||
this.http.get<LineOptionDto[]>(`${this.linesApiBase}/by-client`, { params }).subscribe({
|
this.http.get<LineOptionDto[]>(`${this.linesApiBase}/by-client`, { params }).subscribe({
|
||||||
next: (res) => {
|
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.loadingLines = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@
|
||||||
<h5 class="title">GESTÃO DE VIGÊNCIA</h5>
|
<h5 class="title">GESTÃO DE VIGÊNCIA</h5>
|
||||||
<small class="subtitle">Controle de contratos e fidelização</small>
|
<small class="subtitle">Controle de contratos e fidelização</small>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'">
|
<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="lbl text-danger">Total Vencidos</span>
|
||||||
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
|
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi">
|
|
||||||
<span class="lbl text-brand">Valor Total</span>
|
|
||||||
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls mt-3 mb-2 d-flex flex-wrap gap-3 align-items-center justify-content-between">
|
<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="input-group input-group-sm search-group">
|
||||||
<div class="position-relative">
|
<span class="input-group-text">
|
||||||
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" style="position: absolute; left: 14px; top: 10px; color: var(--muted);"></i>
|
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
|
||||||
<input class="form-control ps-5" placeholder="Pesquisar cliente..." [(ngModel)]="search" (keyup.enter)="fetch(1)" [disabled]="loading">
|
</span>
|
||||||
<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>
|
<input
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div class="page-size d-flex align-items-center gap-2">
|
<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>
|
<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;">
|
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="fetch(1)" [disabled]="loading" style="width: 80px;"></app-select>
|
||||||
<option [ngValue]="10">10</option>
|
|
||||||
<option [ngValue]="20">20</option>
|
|
||||||
<option [ngValue]="50">50</option>
|
|
||||||
<option [ngValue]="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,7 +102,7 @@
|
||||||
|
|
||||||
<div class="group-body" *ngIf="expandedGroup === g.cliente">
|
<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>
|
<small class="text-muted fw-bold">Linhas do Cliente</small>
|
||||||
<span class="chip-muted">Total: {{ g.total | currency:'BRL' }}</span>
|
<span class="chip-muted">Total: {{ g.total | currency:'BRL' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,7 +123,7 @@
|
||||||
<th>EFETIVAÇÃO</th>
|
<th>EFETIVAÇÃO</th>
|
||||||
<th>VENCIMENTO</th>
|
<th>VENCIMENTO</th>
|
||||||
<th class="text-end">TOTAL</th>
|
<th class="text-end">TOTAL</th>
|
||||||
<th style="min-width: 80px;">AÇÕES</th>
|
<th class="actions-col">AÇÕES</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -140,6 +149,8 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="action-group justify-content-center">
|
<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 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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -169,51 +180,262 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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" *ngIf="detailsOpen">
|
||||||
<div class="lg-modal-card">
|
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||||
<div class="modal-header d-flex justify-content-between align-items-center p-3 border-bottom">
|
<div class="modal-header">
|
||||||
<h6 class="mb-0 fw-bold"><i class="bi bi-card-list me-2 text-brand"></i> Detalhes da Linha</h6>
|
<div class="modal-title">
|
||||||
<button class="btn-close" (click)="closeDetails()"></button>
|
<span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
|
||||||
|
Detalhes da Vigência
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body p-4 bg-light-gray">
|
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
|
||||||
<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>
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<small class="text-muted fw-bold text-uppercase">Linha</small>
|
<div class="modal-body bg-light-gray">
|
||||||
<span class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</span>
|
<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>
|
||||||
<div class="d-flex flex-column">
|
<div class="box-body">
|
||||||
<small class="text-muted fw-bold text-uppercase">Conta</small>
|
<div class="info-grid">
|
||||||
<span>{{ selectedRow?.conta || '-' }}</span>
|
<div class="info-item span-2">
|
||||||
|
<span class="lbl">Cliente</span>
|
||||||
|
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column">
|
<div class="info-item">
|
||||||
<small class="text-muted fw-bold text-uppercase">Usuário</small>
|
<span class="lbl">Linha</span>
|
||||||
<span>{{ selectedRow?.usuario || '-' }}</span>
|
<span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column span-2" style="grid-column: span 2;">
|
<div class="info-item">
|
||||||
<small class="text-muted fw-bold text-uppercase">Plano</small>
|
<span class="lbl">Conta</span>
|
||||||
<span class="p-2 bg-white border rounded">{{ selectedRow?.planoContrato || '-' }}</span>
|
<span class="val">{{ selectedRow?.conta || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column">
|
<div class="info-item span-2">
|
||||||
<small class="text-muted fw-bold text-uppercase">Efetivação</small>
|
<span class="lbl">Usuário</span>
|
||||||
<span>{{ selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy' }}</span>
|
<span class="val">{{ selectedRow?.usuario || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column">
|
<div class="info-item span-2">
|
||||||
<small class="text-muted fw-bold text-uppercase">Término</small>
|
<span class="lbl">Plano</span>
|
||||||
<span class="text-danger fw-bold">{{ selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy' }}</span>
|
<span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column span-2 text-end pt-2 border-top">
|
<div class="info-item">
|
||||||
<small class="text-muted fw-bold text-uppercase">Valor Total</small>
|
<span class="lbl">Efetivação</span>
|
||||||
<span class="fw-black text-brand fs-4">{{ (selectedRow?.total || 0) | currency:'BRL' }}</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer p-3 text-end border-top">
|
<div class="modal-footer p-3 text-end border-top">
|
||||||
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
|
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
<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
|
|
@ -1,21 +1,34 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { 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 SortDir = 'asc' | 'desc';
|
||||||
type ToastType = 'success' | 'danger';
|
type ToastType = 'success' | 'danger';
|
||||||
type ViewMode = 'lines' | 'groups';
|
type ViewMode = 'lines' | 'groups';
|
||||||
|
|
||||||
|
interface LineOptionDto {
|
||||||
|
id: string;
|
||||||
|
item: number;
|
||||||
|
linha: string | null;
|
||||||
|
usuario: string | null;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-vigencia',
|
selector: 'app-vigencia',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, CustomSelectComponent],
|
||||||
templateUrl: './vigencia.html',
|
templateUrl: './vigencia.html',
|
||||||
styleUrls: ['./vigencia.scss'],
|
styleUrls: ['./vigencia.scss'],
|
||||||
})
|
})
|
||||||
export class VigenciaComponent implements OnInit {
|
export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
loading = false;
|
loading = false;
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
|
|
||||||
|
|
@ -27,6 +40,7 @@ export class VigenciaComponent implements OnInit {
|
||||||
// Paginação
|
// Paginação
|
||||||
page = 1;
|
page = 1;
|
||||||
pageSize = 10;
|
pageSize = 10;
|
||||||
|
pageSizeOptions = [10, 20, 50, 100];
|
||||||
total = 0;
|
total = 0;
|
||||||
|
|
||||||
// Ordenação
|
// Ordenação
|
||||||
|
|
@ -44,7 +58,6 @@ export class VigenciaComponent implements OnInit {
|
||||||
kpiTotalClientes = 0;
|
kpiTotalClientes = 0;
|
||||||
kpiTotalLinhas = 0;
|
kpiTotalLinhas = 0;
|
||||||
kpiTotalVencidos = 0;
|
kpiTotalVencidos = 0;
|
||||||
kpiValorTotal = 0;
|
|
||||||
|
|
||||||
// === ACORDEÃO ===
|
// === ACORDEÃO ===
|
||||||
expandedGroup: string | null = null;
|
expandedGroup: string | null = null;
|
||||||
|
|
@ -54,18 +67,63 @@ export class VigenciaComponent implements OnInit {
|
||||||
// UI
|
// UI
|
||||||
detailsOpen = false;
|
detailsOpen = false;
|
||||||
selectedRow: VigenciaRow | null = null;
|
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;
|
toastOpen = false;
|
||||||
toastMessage = '';
|
toastMessage = '';
|
||||||
toastType: ToastType = 'success';
|
toastType: ToastType = 'success';
|
||||||
private toastTimer: any = null;
|
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 {
|
ngOnInit(): void {
|
||||||
|
this.isAdmin = this.authService.hasRole('sysadmin');
|
||||||
this.loadClients();
|
this.loadClients();
|
||||||
|
this.loadPlanRules();
|
||||||
this.fetch(1);
|
this.fetch(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||||
|
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||||
|
}
|
||||||
|
|
||||||
setView(mode: ViewMode): void {
|
setView(mode: ViewMode): void {
|
||||||
if (this.viewMode === mode) return;
|
if (this.viewMode === mode) return;
|
||||||
this.viewMode = mode;
|
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 {
|
get totalPages(): number {
|
||||||
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
|
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.kpiTotalClientes = res.kpis.totalClientes;
|
||||||
this.kpiTotalLinhas = res.kpis.totalLinhas;
|
this.kpiTotalLinhas = res.kpis.totalLinhas;
|
||||||
this.kpiTotalVencidos = res.kpis.totalVencidos;
|
this.kpiTotalVencidos = res.kpis.totalVencidos;
|
||||||
this.kpiValorTotal = res.kpis.valorTotal;
|
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
|
@ -197,10 +263,299 @@ export class VigenciaComponent implements OnInit {
|
||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
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; }
|
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
|
||||||
closeDetails() { this.detailsOpen = false; }
|
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) {
|
handleError(err: HttpErrorResponse, msg: string) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.expandedLoading = false;
|
this.expandedLoading = false;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
|
|
@ -16,31 +17,220 @@ export interface LoginPayload {
|
||||||
password: string;
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private baseUrl = `${environment.apiUrl}/auth`;
|
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) {
|
register(payload: RegisterPayload) {
|
||||||
return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload)
|
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) {
|
login(payload: LoginPayload, options?: LoginOptions) {
|
||||||
return this.http.post<{ token: string }>(`${this.baseUrl}/login`, payload)
|
return this.http.post<LoginResponse>(`${this.baseUrl}/login`, payload)
|
||||||
.pipe(tap(r => localStorage.setItem('token', r.token)));
|
.pipe(
|
||||||
|
tap((r) => {
|
||||||
|
const token = this.resolveLoginToken(r);
|
||||||
|
if (!token) return;
|
||||||
|
this.setToken(token, options?.rememberMe ?? false);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
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 {
|
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 {
|
isLoggedIn(): boolean {
|
||||||
return !!this.token;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export type SortDir = 'asc' | 'desc';
|
export type SortDir = 'asc' | 'desc';
|
||||||
export type TipoCliente = 'PF' | 'PJ';
|
export type TipoCliente = 'PF' | 'PJ';
|
||||||
|
|
@ -34,6 +35,22 @@ export interface BillingItem {
|
||||||
|
|
||||||
aparelho?: string | null;
|
aparelho?: string | null;
|
||||||
formaPagamento?: 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 {
|
export interface BillingQuery {
|
||||||
|
|
@ -55,9 +72,13 @@ export interface ApiPagedResult<T> {
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BillingService {
|
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>> {
|
getPaged(q: BillingQuery): Observable<ApiPagedResult<BillingItem>> {
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
|
|
@ -84,4 +105,16 @@ export class BillingService {
|
||||||
|
|
||||||
return this.getPaged(q).pipe(map((res) => res.items ?? []));
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,10 @@ export interface UserDataRow {
|
||||||
item: number;
|
item: number;
|
||||||
linha: string | null;
|
linha: string | null;
|
||||||
cliente: string | null;
|
cliente: string | null;
|
||||||
|
tipoPessoa?: string | null;
|
||||||
|
nome?: string | null;
|
||||||
|
razaoSocial?: string | null;
|
||||||
|
cnpj?: string | null;
|
||||||
cpf: string | null;
|
cpf: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
celular: string | null;
|
celular: string | null;
|
||||||
|
|
@ -26,10 +30,30 @@ export interface UserDataRow {
|
||||||
dataNascimento: string | null;
|
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 {
|
export interface UserDataClientGroup {
|
||||||
cliente: string;
|
cliente: string;
|
||||||
totalRegistros: number;
|
totalRegistros: number;
|
||||||
comCpf: number;
|
comCpf: number;
|
||||||
|
comCnpj: number;
|
||||||
comEmail: number;
|
comEmail: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +61,7 @@ export interface UserDataKpis {
|
||||||
totalRegistros: number;
|
totalRegistros: number;
|
||||||
clientesUnicos: number;
|
clientesUnicos: number;
|
||||||
comCpf: number;
|
comCpf: number;
|
||||||
|
comCnpj: number;
|
||||||
comEmail: number;
|
comEmail: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,12 +75,13 @@ export class DadosUsuariosService {
|
||||||
private readonly baseApi: string;
|
private readonly baseApi: string;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
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`;
|
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroups(opts: {
|
getGroups(opts: {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
tipo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
|
@ -63,6 +89,7 @@ export class DadosUsuariosService {
|
||||||
}): Observable<UserDataGroupResponse> {
|
}): Observable<UserDataGroupResponse> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (opts.search) params = params.set('search', opts.search);
|
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('page', String(opts.page || 1));
|
||||||
params = params.set('pageSize', String(opts.pageSize || 10));
|
params = params.set('pageSize', String(opts.pageSize || 10));
|
||||||
|
|
@ -75,6 +102,7 @@ export class DadosUsuariosService {
|
||||||
getRows(opts: {
|
getRows(opts: {
|
||||||
search?: string;
|
search?: string;
|
||||||
client?: string;
|
client?: string;
|
||||||
|
tipo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
|
@ -83,6 +111,7 @@ export class DadosUsuariosService {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (opts.search) params = params.set('search', opts.search);
|
if (opts.search) params = params.set('search', opts.search);
|
||||||
if (opts.client) params = params.set('client', opts.client);
|
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('page', String(opts.page || 1));
|
||||||
params = params.set('pageSize', String(opts.pageSize || 20));
|
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 });
|
return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getClients(): Observable<string[]> {
|
getClients(tipo?: string): Observable<string[]> {
|
||||||
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`);
|
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> {
|
getById(id: string): Observable<UserDataRow> {
|
||||||
return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
page: number;
|
page: number;
|
||||||
|
|
@ -47,6 +48,8 @@ export interface MobileLineDetail extends MobileLineList {
|
||||||
solicitante?: string | null;
|
solicitante?: string | null;
|
||||||
dataEntregaOpera?: string | null;
|
dataEntregaOpera?: string | null;
|
||||||
dataEntregaCliente?: string | null;
|
dataEntregaCliente?: string | null;
|
||||||
|
dtEfetivacaoServico?: string | null;
|
||||||
|
dtTerminoFidelizacao?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LineOption {
|
export interface LineOption {
|
||||||
|
|
@ -56,10 +59,13 @@ export interface LineOption {
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LinesService {
|
export class LinesService {
|
||||||
// ✅ Mesma base do Swagger (evita redirect no preflight/CORS)
|
private readonly baseUrl: string;
|
||||||
private baseUrl = 'https://localhost:7205/api/lines';
|
|
||||||
|
|
||||||
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>> {
|
getLines(page: number, pageSize: number, search: string): Observable<PagedResult<MobileLineList>> {
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue