Minha alteração
This commit is contained in:
parent
a8e40b640d
commit
49cdaefddf
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,26 +1,31 @@
|
|||
import {
|
||||
ApplicationConfig,
|
||||
LOCALE_ID,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZoneChangeDetection
|
||||
} 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 { routes } from './app.routes';
|
||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||
import { sessionInterceptor } from './interceptors/session.interceptor';
|
||||
import { AppTitleStrategy } from './app-title.strategy';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
{ provide: LOCALE_ID, useValue: 'pt-BR' },
|
||||
provideRouter(routes),
|
||||
{ provide: TitleStrategy, useClass: AppTitleStrategy },
|
||||
provideClientHydration(withEventReplay()),
|
||||
|
||||
// ✅ HttpClient com fetch + interceptor
|
||||
provideHttpClient(
|
||||
withFetch(),
|
||||
withInterceptors([authInterceptor])
|
||||
withInterceptors([authInterceptor, sessionInterceptor])
|
||||
),
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Mureg } from './pages/mureg/mureg';
|
|||
import { Faturamento } from './pages/faturamento/faturamento';
|
||||
|
||||
import { authGuard } from './guards/auth.guard';
|
||||
import { adminGuard } from './guards/admin.guard';
|
||||
import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios';
|
||||
import { VigenciaComponent } from './pages/vigencia/vigencia';
|
||||
import { TrocaNumero } from './pages/troca-numero/troca-numero';
|
||||
|
|
@ -15,24 +16,32 @@ import { Dashboard } from './pages/dashboard/dashboard';
|
|||
import { Notificacoes } from './pages/notificacoes/notificacoes';
|
||||
import { NovoUsuario } from './pages/novo-usuario/novo-usuario';
|
||||
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';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'register', component: Register },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: Register, title: 'Cadastro' },
|
||||
{ path: 'login', component: LoginComponent, title: 'Login' },
|
||||
|
||||
{ path: 'geral', component: Geral, canActivate: [authGuard] },
|
||||
{ path: 'mureg', component: Mureg, canActivate: [authGuard] },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard] },
|
||||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard] },
|
||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard] },
|
||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard] },
|
||||
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard] },
|
||||
{ path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard] },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard] },
|
||||
{ path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' },
|
||||
{ path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' },
|
||||
{ path: 'faturamento', component: Faturamento, canActivate: [authGuard], title: 'Faturamento' },
|
||||
{ path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' },
|
||||
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
|
||||
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
|
||||
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
|
||||
{ path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard], title: 'Novo Usuário' },
|
||||
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' },
|
||||
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
|
||||
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' },
|
||||
{ path: 'historico', component: Historico, canActivate: [authGuard, adminGuard], title: 'Histórico' },
|
||||
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
|
||||
|
||||
// ✅ rota correta
|
||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
|
||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard], title: 'Dashboard' },
|
||||
|
||||
// ✅ compatibilidade: se alguém acessar /portal/dashboard, manda pra /dashboard
|
||||
{ path: 'portal/dashboard', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// src/app/app.ts
|
||||
import { Component, Inject, PLATFORM_ID } from '@angular/core';
|
||||
import { Router, NavigationEnd, RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
|
||||
import { Header } from './components/header/header';
|
||||
import { FooterComponent } from './components/footer/footer';
|
||||
import { AuthService } from './services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
|
@ -36,10 +37,15 @@ export class AppComponent {
|
|||
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
|
||||
'/notificacoes',
|
||||
'/chips-controle-recebidos',
|
||||
'/resumo',
|
||||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/perfil',
|
||||
];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
@Inject(PLATFORM_ID) private platformId: object
|
||||
) {
|
||||
this.router.events.subscribe((event) => {
|
||||
|
|
@ -58,9 +64,30 @@ export class AppComponent {
|
|||
|
||||
// ✅ footer some ao logar + também no login/register
|
||||
this.hideFooter = isLoggedRoute || this.isFullScreenPage;
|
||||
|
||||
// Em SSR não existe storage do navegador.
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
if (isLoggedRoute && !this.hasValidSession()) {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasValidSession(): boolean {
|
||||
const token = this.authService.token;
|
||||
if (!token) return false;
|
||||
|
||||
const payload = this.authService.getTokenPayload();
|
||||
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
|
||||
if (!tenantId) {
|
||||
this.authService.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SSR espera importar { App } de './app/app'
|
||||
|
|
|
|||
|
|
@ -3,24 +3,39 @@
|
|||
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 36px 0 12px;
|
||||
padding: 0 28px 0 12px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
|
|
@ -49,10 +64,12 @@
|
|||
.app-select.sm .app-select-trigger {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
padding-right: 32px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.app-select-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
@ -60,6 +77,10 @@
|
|||
|
||||
|
||||
.app-select-trigger i {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,14 +76,27 @@
|
|||
|
||||
<div class="notif-content">
|
||||
<div class="notif-header">
|
||||
<span class="notif-title">{{ n.linha || 'Sem Linha' }}</span>
|
||||
<span class="notif-date">{{ n.referenciaData ? (n.referenciaData | date:'dd/MM') : '' }}</span>
|
||||
<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">
|
||||
{{ n.tipo === 'Vencido' ? 'Venceu' : 'Vence em' }} - {{ n.cliente || 'Cliente não ident.' }}
|
||||
<span class="notif-verb">{{ getVigenciaLabel(n) }}:</span>
|
||||
<strong class="notif-date-strong" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
|
||||
{{ getVigenciaDate(n) }}
|
||||
</strong>
|
||||
</p>
|
||||
<div class="notif-meta" *ngIf="n.usuario">
|
||||
<i class="bi bi-person"></i> {{ n.usuario }}
|
||||
<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>
|
||||
|
||||
|
|
@ -111,7 +124,7 @@
|
|||
|
||||
<div class="options-dropdown" *ngIf="optionsOpen">
|
||||
<div class="dropdown-arrow"></div>
|
||||
<button type="button" class="options-item" (click)="closeOptions()">
|
||||
<button type="button" class="options-item" (click)="goToProfile()">
|
||||
<i class="bi bi-person-circle"></i> Perfil
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
|
|
@ -413,6 +426,9 @@
|
|||
<a routerLink="/dashboard" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-grid-fill"></i> <span>Dashboard</span>
|
||||
</a>
|
||||
<a routerLink="/resumo" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-table"></i> <span>Resumo</span>
|
||||
</a>
|
||||
<a routerLink="/geral" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-sim"></i> <span>Geral</span>
|
||||
</a>
|
||||
|
|
@ -422,8 +438,14 @@
|
|||
<a routerLink="/faturamento" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-receipt"></i> <span>Faturamento</span>
|
||||
</a>
|
||||
<a routerLink="/parcelamentos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-wallet2"></i> <span>Parcelamentos</span>
|
||||
</a>
|
||||
<a *ngIf="isAdmin" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-clock-history"></i> <span>Histórico</span>
|
||||
</a>
|
||||
<a routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-people-fill"></i> <span>Dados de Usuários</span>
|
||||
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
|
||||
</a>
|
||||
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
|
||||
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
|
||||
|
|
|
|||
|
|
@ -119,9 +119,20 @@ $border-color: #e5e7eb;
|
|||
&.warn { background-color: #fef3c7; color: #d97706; }
|
||||
}
|
||||
.notif-content { flex: 1; }
|
||||
.notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 2px; }
|
||||
.notif-date { font-size: 11px; color: $text-muted; }
|
||||
.notif-desc { margin: 0; font-size: 12px; color: $text-muted; line-height: 1.3; }
|
||||
.notif-header { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 4px; }
|
||||
.notif-title-line { font-weight: 700; color: $text-main; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.notif-line { font-weight: 800; flex: 0 0 auto; }
|
||||
.notif-sep { color: $text-muted; flex: 0 0 auto; }
|
||||
.notif-client { font-weight: 600; color: $text-muted; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; }
|
||||
.notif-desc { margin: 2px 0 6px; font-size: 12px; color: $text-muted; line-height: 1.3; display: flex; align-items: center; gap: 4px; }
|
||||
.notif-verb { font-weight: 600; color: $text-muted; }
|
||||
.notif-date-strong { font-weight: 800; color: $text-main; }
|
||||
.notif-date-strong.warn { color: #d97706; }
|
||||
.notif-date-strong.danger { color: #dc2626; }
|
||||
.notif-meta-lines { display: flex; flex-direction: column; gap: 4px; }
|
||||
.notif-meta-line { display: flex; gap: 6px; font-size: 12px; color: $text-muted; }
|
||||
.meta-label { font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.meta-value { font-weight: 600; color: $text-main; }
|
||||
}
|
||||
|
||||
/* MODAIS GERAIS */
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export class Header {
|
|||
'/notificacoes',
|
||||
'/novo-usuario',
|
||||
'/chips-controle-recebidos',
|
||||
'/resumo',
|
||||
'/parcelamentos',
|
||||
'/historico',
|
||||
'/perfil',
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
|
@ -122,7 +126,9 @@ export class Header {
|
|||
}
|
||||
|
||||
private syncHeaderState(rawUrl: string) {
|
||||
const url = (rawUrl || '').split('?')[0].split('#')[0];
|
||||
let url = (rawUrl || '').split('?')[0].split('#')[0];
|
||||
if (url && !url.startsWith('/')) url = `/${url}`;
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
this.isHome = (url === '/' || url === '');
|
||||
|
||||
|
|
@ -156,6 +162,11 @@ export class Header {
|
|||
this.optionsOpen = false;
|
||||
}
|
||||
|
||||
goToProfile() {
|
||||
this.closeOptions();
|
||||
this.router.navigate(['/perfil']);
|
||||
}
|
||||
|
||||
openCreateUserModal() {
|
||||
if (!this.isAdmin) return;
|
||||
this.createUserOpen = true;
|
||||
|
|
@ -203,6 +214,40 @@ export class Header {
|
|||
});
|
||||
}
|
||||
|
||||
getVigenciaLabel(notification: NotificationDto): string {
|
||||
return notification.tipo === 'Vencido' ? 'Venceu em' : 'Vence em';
|
||||
}
|
||||
|
||||
getVigenciaDate(notification: NotificationDto): string {
|
||||
const raw =
|
||||
notification.dtTerminoFidelizacao ??
|
||||
notification.referenciaData ??
|
||||
notification.data;
|
||||
if (!raw) return '-';
|
||||
return new Date(raw).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
abbreviateName(value?: string | null): string {
|
||||
const name = (value ?? '').trim();
|
||||
if (!name) return '-';
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const maxLen = 18;
|
||||
const full = parts.join(' ');
|
||||
if (full.length <= maxLen) return full;
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const candidate = `${parts[0]} ${parts[1]} ${parts[2][0]}.`;
|
||||
if (candidate.length <= maxLen) return candidate;
|
||||
return `${parts[0]} ${parts[1][0]}.`;
|
||||
}
|
||||
|
||||
const two = `${parts[0]} ${parts[1]}`;
|
||||
if (two.length <= maxLen) return two;
|
||||
return `${parts[0]} ${parts[1][0]}.`;
|
||||
}
|
||||
|
||||
get unreadCount() {
|
||||
return this.notifications.filter(n => !n.lida).length;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 isAdmin = authService.hasRole('admin');
|
||||
if (!isAdmin) {
|
||||
return router.parseUrl('/dashboard');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -10,10 +10,12 @@ export const authGuard: CanActivateFn = () => {
|
|||
|
||||
// SSR: não existe localStorage. Bloqueia e manda pro login.
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return router.parseUrl('/login');
|
||||
// Em SSR não existe acesso ao storage do usuário.
|
||||
// Deixa renderizar e valida no browser após hidratação.
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const token = authService.token;
|
||||
|
||||
if (!token) {
|
||||
return router.parseUrl('/login');
|
||||
|
|
@ -22,7 +24,7 @@ export const authGuard: CanActivateFn = () => {
|
|||
const payload = authService.getTokenPayload();
|
||||
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
|
||||
if (!tenantId) {
|
||||
localStorage.removeItem('token');
|
||||
authService.logout();
|
||||
return router.parseUrl('/login');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// ✅ SSR-safe
|
||||
if (typeof window === 'undefined') return next(req);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const authService = inject(AuthService);
|
||||
const token = authService.token;
|
||||
if (!token) return next(req);
|
||||
|
||||
return next(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
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';
|
||||
|
||||
export const sessionInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const sessionNotice = inject(SessionNoticeService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
if (err?.status === 401) {
|
||||
sessionNotice.handleUnauthorized();
|
||||
} else if (err?.status === 403) {
|
||||
sessionNotice.handleForbidden();
|
||||
}
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -34,7 +34,22 @@
|
|||
<small class="subtitle">Importação e acompanhamento</small>
|
||||
</div>
|
||||
|
||||
<div class="header-actions"></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">
|
||||
|
|
@ -80,14 +95,14 @@
|
|||
<input
|
||||
*ngIf="activeTab === 'chips'"
|
||||
class="form-control"
|
||||
placeholder="Pesquisar Chips..."
|
||||
placeholder="Pesquisar..."
|
||||
[(ngModel)]="chipsSearch"
|
||||
(ngModelChange)="onChipsSearch()"
|
||||
/>
|
||||
<input
|
||||
*ngIf="activeTab === 'controle'"
|
||||
class="form-control"
|
||||
placeholder="Pesquisar Controle..."
|
||||
placeholder="Pesquisar..."
|
||||
[(ngModel)]="controleSearch"
|
||||
(ngModelChange)="onControleSearch()"
|
||||
/>
|
||||
|
|
@ -169,7 +184,7 @@
|
|||
<th>ITEM</th>
|
||||
<th>NÚMERO DO CHIP</th>
|
||||
<th>OBSERVAÇÕES</th>
|
||||
<th>AÇÕES</th>
|
||||
<th class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -182,6 +197,12 @@
|
|||
<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>
|
||||
|
|
@ -258,7 +279,7 @@
|
|||
<th>QTD.</th>
|
||||
<th>CONTEÚDO DA NF</th>
|
||||
<th>DATA DO RECEBIMENTO</th>
|
||||
<th>AÇÕES</th>
|
||||
<th class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -274,6 +295,12 @@
|
|||
<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>
|
||||
|
|
@ -295,7 +322,7 @@
|
|||
<th>NÚMERO DA LINHA</th>
|
||||
<th>VALOR UNIT.</th>
|
||||
<th>VALOR DA NF</th>
|
||||
<th>AÇÕES</th>
|
||||
<th class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -312,6 +339,12 @@
|
|||
<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>
|
||||
|
|
@ -351,7 +384,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen" (click)="closeChipDetail(); closeControleDetail()"></div>
|
||||
<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">
|
||||
|
|
@ -481,5 +514,242 @@
|
|||
</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>
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@
|
|||
text-align: center;
|
||||
|
||||
.title-badge { justify-self: center; margin-bottom: 8px; }
|
||||
.header-actions { justify-self: center; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +174,22 @@
|
|||
}
|
||||
|
||||
.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 */
|
||||
|
|
@ -213,9 +230,9 @@
|
|||
|
||||
/* Pesquisa */
|
||||
.search-group {
|
||||
max-width: 300px;
|
||||
max-width: 270px;
|
||||
border-radius: 12px;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
|
|
@ -261,6 +278,37 @@
|
|||
&: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) */
|
||||
/* ========================================================== */
|
||||
|
|
@ -412,6 +460,7 @@
|
|||
.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 {
|
||||
|
|
@ -428,7 +477,14 @@
|
|||
}
|
||||
|
||||
/* Ações na tabela (estilo Mureg) */
|
||||
.action-group { display: flex; justify-content: center; gap: 6px; }
|
||||
.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;
|
||||
|
|
@ -443,6 +499,8 @@
|
|||
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); }
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
|
|
@ -485,7 +543,7 @@
|
|||
/* ========================================================== */
|
||||
.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-y: auto; 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-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); } }
|
||||
|
|
@ -495,13 +553,61 @@
|
|||
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; 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; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.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; }
|
||||
|
|
@ -517,3 +623,87 @@ div.box-body { padding: 16px; }
|
|||
.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;
|
||||
|
||||
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
|
||||
&::-webkit-details-marker { display: none; }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ 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 } from '../../services/chips-controle.service';
|
||||
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';
|
||||
|
||||
// Interface para o Agrupamento
|
||||
interface ChipGroup {
|
||||
|
|
@ -18,6 +19,13 @@ interface ControleGroup {
|
|||
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'
|
||||
|
|
@ -82,19 +90,45 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
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 http: HttpClient,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
this.fetchChips();
|
||||
this.fetchControle();
|
||||
}
|
||||
|
|
@ -200,6 +234,118 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
confirmChipDelete() {
|
||||
if (!this.chipDeleteTarget) 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;
|
||||
|
|
@ -349,6 +495,216 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
confirmControleDelete() {
|
||||
if (!this.controleDeleteTarget) 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;
|
||||
|
|
|
|||
|
|
@ -22,16 +22,19 @@
|
|||
<div class="geral-header">
|
||||
<div class="header-row-top">
|
||||
<div class="title-badge" data-animate>
|
||||
<i class="bi bi-people-fill"></i> DADOS USUÁRIOS
|
||||
<i class="bi bi-people-fill"></i> DADOS PF/PJ
|
||||
</div>
|
||||
<div class="header-title" data-animate>
|
||||
<h5 class="title mb-0">GESTÃO DE USUÁRIOS</h5>
|
||||
<small class="subtitle">Base de dados agrupada por cliente</small>
|
||||
<h5 class="title mb-0">GESTÃO DE USUÁRIOS PF/PJ</h5>
|
||||
<small class="subtitle">Base de dados separada por pessoa física e jurídica</small>
|
||||
</div>
|
||||
<div class="header-actions d-flex gap-2 justify-content-end" data-animate>
|
||||
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
|
||||
</button>
|
||||
<button *ngIf="isAdmin" type="button" class="btn btn-brand btn-sm" (click)="openCreate()">
|
||||
<i class="bi bi-plus-circle me-1"></i> Novo Usuário
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -51,10 +54,10 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="lbl text-success">Com CPF</span>
|
||||
<span class="lbl text-success">{{ tipoFilter === 'PJ' ? 'Com CNPJ' : 'Com CPF' }}</span>
|
||||
<span class="val text-success">
|
||||
<span *ngIf="loading" class="spinner-border spinner-border-sm text-muted"></span>
|
||||
<span *ngIf="!loading">{{ kpiComCpf || 0 }}</span>
|
||||
<span *ngIf="!loading">{{ tipoFilter === 'PJ' ? (kpiComCnpj || 0) : (kpiComCpf || 0) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
|
|
@ -67,9 +70,17 @@
|
|||
</div>
|
||||
|
||||
<div class="controls mt-3 mb-2" data-animate>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-sm" [class.btn-brand]="tipoFilter === 'PF'" [class.btn-outline-secondary]="tipoFilter !== 'PF'" (click)="setTipoFilter('PF')">
|
||||
Pessoa Física
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm" [class.btn-brand]="tipoFilter === 'PJ'" [class.btn-outline-secondary]="tipoFilter !== 'PJ'" (click)="setTipoFilter('PJ')">
|
||||
Pessoa Jurídica
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm search-group">
|
||||
<span class="input-group-text"><i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i></span>
|
||||
<input class="form-control" placeholder="Pesquisar cliente, linha, cpf..." [(ngModel)]="search" (ngModelChange)="onSearch()" />
|
||||
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="search" (ngModelChange)="onSearch()" />
|
||||
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearFilters()" *ngIf="search"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
|
|
@ -99,7 +110,8 @@
|
|||
<h6 class="mb-0 fw-bold text-dark td-clip" [title]="g.cliente">{{ g.cliente }}</h6>
|
||||
<div class="group-badges">
|
||||
<span class="badge-pill total">{{ g.totalRegistros }} Registros</span>
|
||||
<span class="badge-pill ok" *ngIf="g.comCpf > 0">{{ g.comCpf }} CPF</span>
|
||||
<span class="badge-pill ok" *ngIf="tipoFilter === 'PF' && g.comCpf > 0">{{ g.comCpf }} CPF</span>
|
||||
<span class="badge-pill ok" *ngIf="tipoFilter === 'PJ' && g.comCnpj > 0">{{ g.comCnpj }} CNPJ</span>
|
||||
<span class="badge-pill ok" *ngIf="g.comEmail > 0">{{ g.comEmail }} Email</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,10 +134,10 @@
|
|||
<tr>
|
||||
<th>ITEM</th>
|
||||
<th>LINHA</th>
|
||||
<th>CPF</th>
|
||||
<th>{{ tipoFilter === 'PJ' ? 'CNPJ' : 'CPF' }}</th>
|
||||
<th>E-MAIL</th>
|
||||
<th>CELULAR</th>
|
||||
<th style="min-width: 80px;">AÇÕES</th>
|
||||
<th class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -135,12 +147,14 @@
|
|||
<tr *ngFor="let r of groupRows; trackBy: trackById" class="table-row-item">
|
||||
<td class="text-muted fw-bold">{{ r.item }}</td>
|
||||
<td class="fw-black text-blue">{{ r.linha }}</td>
|
||||
<td class="small font-monospace">{{ r.cpf || '-' }}</td>
|
||||
<td class="small font-monospace">{{ tipoFilter === 'PJ' ? (r.cnpj || '-') : (r.cpf || '-') }}</td>
|
||||
<td class="text-muted small td-clip" [title]="r.email">{{ r.email || '-' }}</td>
|
||||
<td class="text-muted small">{{ r.celular || '-' }}</td>
|
||||
<td>
|
||||
<div class="action-group justify-content-center">
|
||||
<button class="btn-icon primary" (click)="openDetails(r)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -167,9 +181,9 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-backdrop-custom" *ngIf="detailsOpen" (click)="closeDetails()"></div>
|
||||
<div class="modal-custom" *ngIf="detailsOpen">
|
||||
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||
<div class="modal-backdrop-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
|
||||
<div class="modal-custom" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()">
|
||||
<div *ngIf="detailsOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg primary-soft"><i class="bi bi-person-vcard"></i></span>
|
||||
|
|
@ -184,10 +198,11 @@
|
|||
<div class="box-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-field span-2"><label>CLIENTE</label><div class="fw-bold">{{ selectedRow?.cliente }}</div></div>
|
||||
<div class="form-field"><label>TIPO</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'PESSOA JURÍDICA' : 'PESSOA FÍSICA' }}</div></div>
|
||||
<div class="form-field span-2"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'RAZÃO SOCIAL' : 'NOME' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.razaoSocial || selectedRow?.cliente || '-') : (selectedRow?.nome || selectedRow?.cliente || '-') }}</div></div>
|
||||
<div class="form-field"><label>LINHA</label><div class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</div></div>
|
||||
<div class="form-field"><label>ITEM</label><div>{{ selectedRow?.item }}</div></div>
|
||||
|
||||
<div class="form-field"><label>CPF</label><div>{{ selectedRow?.cpf || '-' }}</div></div>
|
||||
<div class="form-field"><label>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? 'CNPJ' : 'CPF' }}</label><div>{{ (selectedRow?.tipoPessoa || 'PF') === 'PJ' ? (selectedRow?.cnpj || '-') : (selectedRow?.cpf || '-') }}</div></div>
|
||||
<div class="form-field"><label>RG</label><div>{{ selectedRow?.rg || '-' }}</div></div>
|
||||
|
||||
<div class="form-field span-2"><label>E-MAIL</label><div>{{ selectedRow?.email || '-' }}</div></div>
|
||||
|
|
@ -202,5 +217,205 @@
|
|||
</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">
|
||||
<div class="form-field span-2"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" /></div>
|
||||
<div class="form-field"><label>Tipo</label>
|
||||
<select class="form-control form-control-sm" [(ngModel)]="createModel.tipoPessoa" (ngModelChange)="onCreateTipoChange()">
|
||||
<option value="PF">Pessoa Física</option>
|
||||
<option value="PJ">Pessoa Jurídica</option>
|
||||
</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"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="createModel.linha" /></div>
|
||||
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.item" /></div>
|
||||
<div class="form-field" *ngIf="(createModel.tipoPessoa || 'PF') === 'PF'"><label>CPF</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cpf" /></div>
|
||||
<div class="form-field" *ngIf="(createModel.tipoPessoa || 'PF') === 'PJ'"><label>CNPJ</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cnpj" /></div>
|
||||
<div class="form-field"><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"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="createModel.celular" /></div>
|
||||
<div class="form-field"><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">
|
||||
<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.tipoPessoa" (ngModelChange)="onEditTipoChange()">
|
||||
<option value="PF">Pessoa Física</option>
|
||||
<option value="PJ">Pessoa Jurídica</option>
|
||||
</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"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></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" *ngIf="(editModel.tipoPessoa || 'PF') === 'PF'">
|
||||
<label>CPF</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.cpf" />
|
||||
</div>
|
||||
<div class="form-field" *ngIf="(editModel.tipoPessoa || 'PF') === 'PJ'">
|
||||
<label>CNPJ</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.cnpj" />
|
||||
</div>
|
||||
<div class="form-field"><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">
|
||||
<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"><label>Celular</label><input class="form-control form-control-sm" [(ngModel)]="editModel.celular" /></div>
|
||||
<div class="form-field"><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>
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,22 @@
|
|||
.header-title { justify-self: center; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.title { font-size: 26px; font-weight: 950; letter-spacing: -0.3px; color: var(--text); margin-top: 10px; margin-bottom: 0; }
|
||||
.subtitle { color: rgba(17, 18, 20, 0.65); font-weight: 700; }
|
||||
.header-actions { justify-self: end; }
|
||||
.header-actions {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-brand {
|
||||
|
|
@ -241,12 +256,24 @@
|
|||
.td-clip { overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
|
||||
.empty-state { background: rgba(255,255,255,0.4); }
|
||||
|
||||
.actions-col { min-width: 152px; }
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer;
|
||||
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
|
||||
&.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); }
|
||||
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
|
|
@ -256,17 +283,167 @@
|
|||
|
||||
/* MODALS */
|
||||
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
|
||||
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; }
|
||||
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; }
|
||||
.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: clamp(12px, 2.2vw, 20px); }
|
||||
.modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; }
|
||||
.modal-xl-custom { width: min(1050px, 95vw); max-height: 86vh; }
|
||||
@keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||||
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } }
|
||||
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-header { padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center; .modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; } .icon-bg { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 16px; &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; } } .btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; border:none; cursor: pointer; &:hover { color: var(--brand); } } }
|
||||
.modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-body .box-body { overflow: visible; }
|
||||
.modal-footer { flex-shrink: 0; }
|
||||
|
||||
.modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
|
||||
.modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
|
||||
.modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
|
||||
.modal-card.create-modal .edit-sections { gap: 14px; }
|
||||
.modal-card.create-modal .detail-box { border: 1px solid rgba(227, 61, 207, 0.14); box-shadow: 0 10px 24px rgba(17, 18, 20, 0.06); }
|
||||
.modal-card.create-modal .box-header { color: var(--brand); background: linear-gradient(135deg, rgba(227, 61, 207, 0.1), rgba(3, 15, 170, 0.07)); }
|
||||
.modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
|
||||
.modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
|
||||
.modal-card.create-modal .form-control,
|
||||
.modal-card.create-modal .form-select { min-height: 40px; }
|
||||
.modal-card.create-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px !important;
|
||||
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.95));
|
||||
}
|
||||
.modal-card.create-modal .modal-footer .btn {
|
||||
border-radius: 12px;
|
||||
font-weight: 900;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
.modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.modal-card { border-radius: 16px; }
|
||||
.modal-header { padding: 12px 16px; }
|
||||
.modal-body { padding: 16px; }
|
||||
.modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
|
||||
.modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
|
||||
}
|
||||
|
||||
/* FORM & DETAILS */
|
||||
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
|
||||
div.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; }
|
||||
div.box-header {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.box-body { padding: 16px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 600px) { grid-template-columns: 1fr; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } &.span-2 { grid-column: span 2; } }
|
||||
.form-control { border-radius: 8px; border: 1px solid rgba(17,18,20,0.15); &:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); outline: none; } }
|
||||
|
||||
.edit-sections { display: grid; gap: 12px; }
|
||||
.edit-sections .detail-box { border: 1px solid rgba(17, 18, 20, 0.08); box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06); }
|
||||
|
||||
summary.box-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
|
||||
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); }
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,23 @@ import {
|
|||
UserDataClientGroup,
|
||||
UserDataRow,
|
||||
UserDataGroupResponse,
|
||||
PagedResult
|
||||
PagedResult,
|
||||
UpdateUserDataRequest,
|
||||
CreateUserDataRequest
|
||||
} from '../../services/dados-usuarios.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { LinesService, MobileLineDetail } from '../../services/lines.service';
|
||||
|
||||
type ViewMode = 'lines' | 'groups';
|
||||
|
||||
interface LineOptionDto {
|
||||
id: string;
|
||||
item: number;
|
||||
linha: string | null;
|
||||
usuario: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dados-usuarios',
|
||||
standalone: true,
|
||||
|
|
@ -30,6 +42,7 @@ export class DadosUsuarios implements OnInit {
|
|||
|
||||
// Filtros
|
||||
search = '';
|
||||
tipoFilter: 'PF' | 'PJ' = 'PF';
|
||||
|
||||
// Paginação
|
||||
page = 1;
|
||||
|
|
@ -52,6 +65,7 @@ export class DadosUsuarios implements OnInit {
|
|||
kpiTotalRegistros = 0;
|
||||
kpiClientesUnicos = 0;
|
||||
kpiComCpf = 0;
|
||||
kpiComCnpj = 0;
|
||||
kpiComEmail = 0;
|
||||
|
||||
// ACORDEÃO
|
||||
|
|
@ -62,15 +76,38 @@ export class DadosUsuarios implements OnInit {
|
|||
// Modal / Toast
|
||||
detailsOpen = false;
|
||||
selectedRow: UserDataRow | null = null;
|
||||
editOpen = false;
|
||||
editSaving = false;
|
||||
editModel: UserDataRow | null = null;
|
||||
editDateNascimento = '';
|
||||
editingId: string | null = null;
|
||||
deleteOpen = false;
|
||||
deleteTarget: UserDataRow | null = null;
|
||||
|
||||
createOpen = false;
|
||||
createSaving = false;
|
||||
createModel: any = null;
|
||||
createDateNascimento = '';
|
||||
clientsFromGeral: string[] = [];
|
||||
lineOptionsCreate: LineOptionDto[] = [];
|
||||
createClientsLoading = false;
|
||||
createLinesLoading = false;
|
||||
|
||||
isAdmin = false;
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: 'success' | 'danger' = 'success';
|
||||
private toastTimer: any = null;
|
||||
private searchTimer: any = null;
|
||||
|
||||
constructor(private service: DadosUsuariosService) {}
|
||||
constructor(
|
||||
private service: DadosUsuariosService,
|
||||
private authService: AuthService,
|
||||
private linesService: LinesService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
this.fetch(1);
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +165,7 @@ export class DadosUsuarios implements OnInit {
|
|||
private fetchGroups() {
|
||||
this.service.getGroups({
|
||||
search: this.search?.trim(),
|
||||
tipo: this.tipoFilter,
|
||||
page: this.page,
|
||||
pageSize: this.pageSize,
|
||||
sortBy: this.sortBy,
|
||||
|
|
@ -140,6 +178,7 @@ export class DadosUsuarios implements OnInit {
|
|||
this.kpiTotalRegistros = res.kpis.totalRegistros;
|
||||
this.kpiClientesUnicos = res.kpis.clientesUnicos;
|
||||
this.kpiComCpf = res.kpis.comCpf;
|
||||
this.kpiComCnpj = res.kpis.comCnpj;
|
||||
this.kpiComEmail = res.kpis.comEmail;
|
||||
|
||||
this.loading = false;
|
||||
|
|
@ -168,6 +207,7 @@ export class DadosUsuarios implements OnInit {
|
|||
|
||||
this.service.getRows({
|
||||
client: g.cliente,
|
||||
tipo: this.tipoFilter,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
sortBy: 'item',
|
||||
|
|
@ -193,6 +233,15 @@ export class DadosUsuarios implements OnInit {
|
|||
}, 400);
|
||||
}
|
||||
|
||||
setTipoFilter(tipo: 'PF' | 'PJ') {
|
||||
if (this.tipoFilter === tipo) return;
|
||||
this.tipoFilter = tipo;
|
||||
this.page = 1;
|
||||
this.expandedGroup = null;
|
||||
this.groupRows = [];
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
clearFilters() { this.search = ''; this.fetch(1); }
|
||||
|
||||
onPageSizeChange() {
|
||||
|
|
@ -208,7 +257,13 @@ export class DadosUsuarios implements OnInit {
|
|||
openDetails(row: UserDataRow) {
|
||||
this.service.getById(row.id).subscribe({
|
||||
next: (fullData: UserDataRow) => {
|
||||
this.selectedRow = fullData;
|
||||
const tipo = this.normalizeTipo(fullData);
|
||||
this.selectedRow = {
|
||||
...fullData,
|
||||
tipoPessoa: tipo,
|
||||
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
|
||||
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
|
||||
};
|
||||
this.detailsOpen = true;
|
||||
},
|
||||
error: (err: HttpErrorResponse) => this.showToast('Erro ao abrir detalhes', 'danger')
|
||||
|
|
@ -217,9 +272,315 @@ export class DadosUsuarios implements OnInit {
|
|||
|
||||
closeDetails() { this.detailsOpen = false; }
|
||||
|
||||
openEdit(row: UserDataRow) {
|
||||
if (!this.isAdmin) return;
|
||||
this.service.getById(row.id).subscribe({
|
||||
next: (fullData: UserDataRow) => {
|
||||
this.editingId = fullData.id;
|
||||
const tipo = this.normalizeTipo(fullData);
|
||||
this.editModel = {
|
||||
...fullData,
|
||||
tipoPessoa: tipo,
|
||||
nome: fullData.nome || (tipo === 'PF' ? fullData.cliente : ''),
|
||||
razaoSocial: fullData.razaoSocial || (tipo === 'PJ' ? fullData.cliente : '')
|
||||
};
|
||||
this.editDateNascimento = this.toDateInput(fullData.dataNascimento);
|
||||
this.editOpen = true;
|
||||
},
|
||||
error: () => this.showToast('Erro ao abrir edição', 'danger')
|
||||
});
|
||||
}
|
||||
|
||||
closeEdit() {
|
||||
this.editOpen = false;
|
||||
this.editSaving = false;
|
||||
this.editModel = null;
|
||||
this.editDateNascimento = '';
|
||||
this.editingId = null;
|
||||
}
|
||||
|
||||
onEditTipoChange() {
|
||||
if (!this.editModel) return;
|
||||
const tipo = (this.editModel.tipoPessoa ?? 'PF').toString().toUpperCase();
|
||||
this.editModel.tipoPessoa = tipo;
|
||||
if (tipo === 'PJ') {
|
||||
this.editModel.cpf = '';
|
||||
if (!this.editModel.razaoSocial) this.editModel.razaoSocial = this.editModel.cliente;
|
||||
} else {
|
||||
this.editModel.cnpj = '';
|
||||
if (!this.editModel.nome) this.editModel.nome = this.editModel.cliente;
|
||||
}
|
||||
}
|
||||
|
||||
saveEdit() {
|
||||
if (!this.editModel || !this.editingId) return;
|
||||
this.editSaving = true;
|
||||
|
||||
const tipo = (this.editModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
|
||||
const cliente = tipo === 'PJ'
|
||||
? (this.editModel.razaoSocial || this.editModel.cliente)
|
||||
: (this.editModel.nome || this.editModel.cliente);
|
||||
|
||||
const payload: UpdateUserDataRequest = {
|
||||
item: this.toNullableNumber(this.editModel.item),
|
||||
linha: this.editModel.linha,
|
||||
cliente,
|
||||
tipoPessoa: tipo,
|
||||
nome: this.editModel.nome,
|
||||
razaoSocial: this.editModel.razaoSocial,
|
||||
cnpj: this.editModel.cnpj,
|
||||
cpf: this.editModel.cpf,
|
||||
rg: this.editModel.rg,
|
||||
email: this.editModel.email,
|
||||
endereco: this.editModel.endereco,
|
||||
celular: this.editModel.celular,
|
||||
telefoneFixo: this.editModel.telefoneFixo,
|
||||
dataNascimento: this.dateInputToIso(this.editDateNascimento)
|
||||
};
|
||||
|
||||
this.service.update(this.editingId, payload).subscribe({
|
||||
next: () => {
|
||||
this.editSaving = false;
|
||||
this.closeEdit();
|
||||
this.fetch();
|
||||
this.showToast('Registro atualizado!', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.editSaving = false;
|
||||
this.showToast('Erro ao salvar alterações.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.preloadGeralClients();
|
||||
}
|
||||
|
||||
closeCreate() {
|
||||
this.createOpen = false;
|
||||
this.createSaving = false;
|
||||
this.createModel = null;
|
||||
}
|
||||
|
||||
private resetCreateModel() {
|
||||
this.createModel = {
|
||||
selectedClient: '',
|
||||
mobileLineId: '',
|
||||
item: '',
|
||||
linha: '',
|
||||
cliente: '',
|
||||
tipoPessoa: this.tipoFilter,
|
||||
nome: '',
|
||||
razaoSocial: '',
|
||||
cnpj: '',
|
||||
cpf: '',
|
||||
rg: '',
|
||||
email: '',
|
||||
endereco: '',
|
||||
celular: '',
|
||||
telefoneFixo: ''
|
||||
};
|
||||
this.createDateNascimento = '';
|
||||
this.lineOptionsCreate = [];
|
||||
this.createLinesLoading = false;
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
|
||||
private preloadGeralClients() {
|
||||
this.createClientsLoading = true;
|
||||
this.linesService.getClients().subscribe({
|
||||
next: (list) => {
|
||||
this.clientsFromGeral = list ?? [];
|
||||
this.createClientsLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.clientsFromGeral = [];
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCreateClientChange() {
|
||||
const c = (this.createModel?.selectedClient ?? '').trim();
|
||||
this.createModel.mobileLineId = '';
|
||||
this.createModel.linha = '';
|
||||
this.createModel.cliente = c;
|
||||
this.lineOptionsCreate = [];
|
||||
|
||||
if (c) this.loadLinesForClient(c);
|
||||
}
|
||||
|
||||
onCreateTipoChange() {
|
||||
const tipo = (this.createModel?.tipoPessoa ?? 'PF').toString().toUpperCase();
|
||||
this.createModel.tipoPessoa = tipo;
|
||||
if (tipo === 'PJ') {
|
||||
this.createModel.cpf = '';
|
||||
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
|
||||
} else {
|
||||
this.createModel.cnpj = '';
|
||||
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
|
||||
}
|
||||
}
|
||||
|
||||
private loadLinesForClient(cliente: string) {
|
||||
const c = (cliente ?? '').trim();
|
||||
if (!c) return;
|
||||
|
||||
this.createLinesLoading = true;
|
||||
this.linesService.getLinesByClient(c).subscribe({
|
||||
next: (items: any[]) => {
|
||||
const mapped: LineOptionDto[] = (items ?? [])
|
||||
.filter(x => !!String(x?.id ?? '').trim())
|
||||
.map(x => ({
|
||||
id: String(x.id),
|
||||
item: Number(x.item ?? 0),
|
||||
linha: x.linha ?? null,
|
||||
usuario: x.usuario ?? null,
|
||||
label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}`
|
||||
}))
|
||||
.filter(x => !!String(x.linha ?? '').trim());
|
||||
|
||||
this.lineOptionsCreate = mapped;
|
||||
this.createLinesLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.lineOptionsCreate = [];
|
||||
this.createLinesLoading = false;
|
||||
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCreateLineChange() {
|
||||
const id = String(this.createModel?.mobileLineId ?? '').trim();
|
||||
if (!id) return;
|
||||
|
||||
this.linesService.getById(id).subscribe({
|
||||
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
|
||||
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
|
||||
});
|
||||
}
|
||||
|
||||
private applyLineDetailToCreate(d: MobileLineDetail) {
|
||||
this.createModel.linha = d.linha ?? '';
|
||||
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
|
||||
if (!String(this.createModel.item ?? '').trim() && d.item) {
|
||||
this.createModel.item = String(d.item);
|
||||
}
|
||||
|
||||
if ((this.createModel.tipoPessoa ?? '').toUpperCase() === 'PJ') {
|
||||
if (!this.createModel.razaoSocial) this.createModel.razaoSocial = this.createModel.cliente;
|
||||
} else {
|
||||
if (!this.createModel.nome) this.createModel.nome = this.createModel.cliente;
|
||||
}
|
||||
}
|
||||
|
||||
saveCreate() {
|
||||
if (!this.createModel) return;
|
||||
this.createSaving = true;
|
||||
|
||||
const tipo = (this.createModel.tipoPessoa ?? this.tipoFilter).toString().toUpperCase();
|
||||
const cliente = tipo === 'PJ'
|
||||
? (this.createModel.razaoSocial || this.createModel.cliente)
|
||||
: (this.createModel.nome || this.createModel.cliente);
|
||||
|
||||
const payload: CreateUserDataRequest = {
|
||||
item: this.toNullableNumber(this.createModel.item),
|
||||
linha: this.createModel.linha,
|
||||
cliente,
|
||||
tipoPessoa: tipo,
|
||||
nome: this.createModel.nome,
|
||||
razaoSocial: this.createModel.razaoSocial,
|
||||
cnpj: this.createModel.cnpj,
|
||||
cpf: this.createModel.cpf,
|
||||
rg: this.createModel.rg,
|
||||
email: this.createModel.email,
|
||||
endereco: this.createModel.endereco,
|
||||
celular: this.createModel.celular,
|
||||
telefoneFixo: this.createModel.telefoneFixo,
|
||||
dataNascimento: this.dateInputToIso(this.createDateNascimento)
|
||||
};
|
||||
|
||||
this.service.create(payload).subscribe({
|
||||
next: () => {
|
||||
this.createSaving = false;
|
||||
this.closeCreate();
|
||||
this.fetch();
|
||||
this.showToast('Usuário criado com sucesso!', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.createSaving = false;
|
||||
this.showToast('Erro ao criar usuário.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openDelete(row: UserDataRow) {
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = row;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
||||
cancelDelete() {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
}
|
||||
|
||||
confirmDelete() {
|
||||
if (!this.deleteTarget) return;
|
||||
const id = this.deleteTarget.id;
|
||||
this.service.remove(id).subscribe({
|
||||
next: () => {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.fetch();
|
||||
this.showToast('Registro removido.', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.showToast('Erro ao remover.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackById(_: number, row: UserDataRow) { return row.id; }
|
||||
trackByCliente(_: number, g: UserDataClientGroup) { return g.cliente; }
|
||||
|
||||
private toDateInput(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private dateInputToIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
private toNullableNumber(value: any): number | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
private normalizeTipo(row: UserDataRow | null | undefined): 'PF' | 'PJ' {
|
||||
const t = (row?.tipoPessoa ?? '').toString().trim().toUpperCase();
|
||||
if (t === 'PJ') return 'PJ';
|
||||
if (t === 'PF') return 'PF';
|
||||
if (row?.cnpj) return 'PJ';
|
||||
return 'PF';
|
||||
}
|
||||
|
||||
showToast(msg: string, type: 'success' | 'danger') {
|
||||
this.toastMessage = msg; this.toastType = type; this.toastOpen = true;
|
||||
if(this.toastTimer) clearTimeout(this.toastTimer);
|
||||
|
|
|
|||
|
|
@ -1,275 +1,327 @@
|
|||
<section class="dashboard-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> Dashboard
|
||||
</span>
|
||||
<p class="subtitle">Resumo e indicadores do ambiente.</p>
|
||||
</div>
|
||||
<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="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 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>
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<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="status-pie-grid">
|
||||
<div class="pie-wrap">
|
||||
<canvas #chartStatusPie></canvas>
|
||||
<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="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 class="card-body-adicionais">
|
||||
<div class="chart-wrapper-pie-sm">
|
||||
<canvas #chartAdicionaisComparativo></canvas>
|
||||
</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 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<!-- ✅ 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 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-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">
|
||||
<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>
|
||||
|
||||
<!-- 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 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="chart-wrap">
|
||||
<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>Valor Total Line</span>
|
||||
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.valorTotalLine) }}</strong>
|
||||
</div>
|
||||
<div class="metric-line">
|
||||
<span>Lucro Total Line</span>
|
||||
<strong>{{ formatMoneySafe(resumoDiferencaPjPf.lucroTotalLine) }}</strong>
|
||||
</div>
|
||||
<div class="metric-line">
|
||||
<span>Qtd Linhas</span>
|
||||
<strong>{{ formatInt(resumoDiferencaPjPf.qtdLinhas) }}</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="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 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-wrap">
|
||||
<div class="chart-wrapper-bar compact-half">
|
||||
<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 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 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,22 +1,42 @@
|
|||
/* ========================================================== */
|
||||
/* VARIÁVEIS & SETUP (Consistente com Geral/Resumo) */
|
||||
/* ========================================================== */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
--brand-primary: #E33DCF;
|
||||
--brand-blue: #030FAA;
|
||||
--brand-deep: #B832A8;
|
||||
--brand-violet: #6A55FF;
|
||||
--brand-soft: rgba(227, 61, 207, 0.2);
|
||||
--brand-blue-soft: rgba(3, 15, 170, 0.2);
|
||||
--chart-pink: var(--brand-primary);
|
||||
--chart-pink-dark: var(--brand-deep);
|
||||
--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-blue: var(--brand-blue);
|
||||
--chart-blue-soft: var(--brand-blue-soft);
|
||||
--chart-violet: var(--brand-violet);
|
||||
--chart-dark: #B832A8;
|
||||
|
||||
display: block;
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* ✅ remove footer nessa página */
|
||||
/* Remove footer se necessário (herdado do antigo css) */
|
||||
:host ::ng-deep footer,
|
||||
:host ::ng-deep .footer,
|
||||
:host ::ng-deep .portal-footer,
|
||||
|
|
@ -24,370 +44,579 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* LAYOUT BASE */
|
||||
/* ========================================================== */
|
||||
.dashboard-page {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ✅ SUBIR MAIS A PÁGINA (antes 44px) */
|
||||
.wrap {
|
||||
padding-top: 15px; /* ✅ mais perto do header */
|
||||
padding-bottom: 16px;
|
||||
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 {
|
||||
.container-dashboard {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-width: 1380px; /* Largura executiva */
|
||||
margin: 0 auto;
|
||||
padding: 0 14px;
|
||||
padding: 24px 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
/* Animações */
|
||||
.fade-in-up {
|
||||
opacity: 0;
|
||||
animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.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); }
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
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 {
|
||||
/* ========================================================== */
|
||||
/* HEADER */
|
||||
/* ========================================================== */
|
||||
.page-head {
|
||||
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;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
|
||||
@media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||||
}
|
||||
|
||||
.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 {
|
||||
.badge-pill {
|
||||
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); }
|
||||
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; }
|
||||
}
|
||||
|
||||
/* Status grid */
|
||||
.status-pie-grid {
|
||||
.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: 360px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
@media (max-width: 900px) { grid-template-columns: 1fr; }
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.pie-wrap {
|
||||
position: relative;
|
||||
height: 260px;
|
||||
padding: 6px;
|
||||
.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);
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
border-color: rgba(227, 61, 207, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.status-metrics {
|
||||
.hero-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--brand), #9d2ec5);
|
||||
color: #fff;
|
||||
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: var(--chart-pink);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 99px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ✅ DOTS COM CORES "PADRÃO DE DASHBOARD" */
|
||||
.dot.d1 { background: var(--chart-pink); }
|
||||
.dot.d2 { background: var(--chart-blue); }
|
||||
.dot.d3 { background: var(--chart-pink-dark); }
|
||||
.dot.d4 { background: var(--chart-violet); }
|
||||
.dot.d5 { background: var(--chart-pink-soft); }
|
||||
.dot.d6 { background: var(--chart-blue-soft); }
|
||||
|
||||
.meta .k {
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
color: rgba(17,18,20,0.65);
|
||||
}
|
||||
|
||||
.meta .v {
|
||||
font-weight: 900;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
color: rgba(17,18,20,0.92);
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.hero-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.charts-vigencia {
|
||||
margin-top: 10px; /* ✅ era 12px */
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
padding: 12px 12px 16px;
|
||||
/* ========================================================== */
|
||||
/* 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%;
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-wrap {
|
||||
padding: 12px 12px 16px;
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.tablex {
|
||||
/* ========================================================== */
|
||||
/* 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%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.tablex th,
|
||||
.tablex td {
|
||||
padding: 12px 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(17, 18, 20, 0.8);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tablex th {
|
||||
color: rgba(17, 18, 20, 0.65);
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 10px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-bottom: 6px;
|
||||
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
|
||||
.tablex tbody tr {
|
||||
.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;
|
||||
box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.tablex tbody tr:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 22px rgba(17, 18, 20, 0.12);
|
||||
.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); }
|
||||
}
|
||||
|
||||
.tablex tbody td {
|
||||
border-top: 1px solid rgba(17, 18, 20, 0.06);
|
||||
border-bottom: 1px solid rgba(17, 18, 20, 0.06);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
.toolbar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tablex tbody td:first-child {
|
||||
border-left: 1px solid rgba(17, 18, 20, 0.06);
|
||||
border-top-left-radius: 12px;
|
||||
border-bottom-left-radius: 12px;
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
label { font-size: 12px; font-weight: 700; color: var(--text-muted); white-space: nowrap; }
|
||||
}
|
||||
|
||||
.tablex tbody td:last-child {
|
||||
border-right: 1px solid rgba(17, 18, 20, 0.06);
|
||||
border-top-right-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
|
||||
.tablex tbody tr:nth-child(even) td {
|
||||
background: rgba(248, 249, 255, 0.9);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(17, 18, 20, 0.55);
|
||||
.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; }
|
||||
}
|
||||
|
||||
.cell-strong {
|
||||
font-weight: 900;
|
||||
color: rgba(17, 18, 20, 0.92);
|
||||
.divider-v {
|
||||
width: 1px; height: 24px; background: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.cell-clip {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.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
|
|
@ -168,7 +168,7 @@
|
|||
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Pesquisar por cliente, aparelho, forma de pagamento..."
|
||||
placeholder="Pesquisar..."
|
||||
[(ngModel)]="searchTerm"
|
||||
(ngModelChange)="onSearch()" />
|
||||
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
<th colspan="2" class="th-block th-vivo">VIVO</th>
|
||||
<th colspan="2" class="th-block th-line">LINE MÓVEL</th>
|
||||
|
||||
<th rowspan="2">AÇÕES</th>
|
||||
<th rowspan="2" class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
|
||||
<tr class="thead-sub">
|
||||
|
|
@ -286,6 +286,8 @@
|
|||
<div class="action-group justify-content-center">
|
||||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<button class="btn-icon success" (click)="onComparativo(r)" title="Comparativo Vivo x Line"><i class="bi bi-columns-gap"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onDelete(r)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -325,9 +327,9 @@
|
|||
</section>
|
||||
|
||||
<!-- MODAIS -->
|
||||
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()"></div>
|
||||
<div class="modal-backdrop-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()"></div>
|
||||
|
||||
<div class="modal-custom" *ngIf="detailOpen || compareOpen" (click)="closeAllModals()">
|
||||
<div class="modal-custom" *ngIf="detailOpen || compareOpen || editOpen || deleteOpen" (click)="closeAllModals()">
|
||||
|
||||
<!-- DETAIL MODAL -->
|
||||
<div *ngIf="detailOpen" #detailModal class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
|
||||
|
|
@ -446,6 +448,127 @@
|
|||
</ng-template>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODAL -->
|
||||
<div *ngIf="editOpen" class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg primary-soft"><i class="bi bi-pencil-square"></i></span> Editar Faturamento
|
||||
</div>
|
||||
<button class="btn btn-sm btn-icon" (click)="closeEdit()"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body modern-body bg-light-gray" *ngIf="editModel">
|
||||
<div class="edit-sections">
|
||||
<details open class="detail-box">
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
</summary>
|
||||
<div class="box-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-field span-2">
|
||||
<label>Cliente</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Tipo</label>
|
||||
<select class="form-control form-control-sm" [(ngModel)]="editModel.tipo">
|
||||
<option value="PF">PF</option>
|
||||
<option value="PJ">PJ</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Item</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Qtd Linhas</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.qtdLinhas" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Aparelho</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelho" />
|
||||
</div>
|
||||
<div class="form-field span-2">
|
||||
<label>Forma de Pagamento</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="editModel.formaPagamento" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open class="detail-box vivo-border">
|
||||
<summary class="box-header header-vivo">
|
||||
<span><i class="bi bi-telephone-fill me-2"></i> Faturamento Vivo</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
</summary>
|
||||
<div class="box-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Franquia Vivo</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaVivo" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Valor Vivo (R$)</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoVivo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open class="detail-box line-border">
|
||||
<summary class="box-header header-line">
|
||||
<span><i class="bi bi-hdd-network-fill me-2"></i> Faturamento Line</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
</summary>
|
||||
<div class="box-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Franquia Line</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.franquiaLine" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Valor Line (R$)</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.valorContratoLine" />
|
||||
</div>
|
||||
<div class="form-field span-2">
|
||||
<label>Lucro (R$)</label>
|
||||
<input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.lucro" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer p-3 text-end border-top bg-white">
|
||||
<button class="btn btn-glass btn-sm me-2" (click)="closeEdit()">Cancelar</button>
|
||||
<button class="btn btn-primary btn-sm" [disabled]="editSaving" (click)="saveEdit()">
|
||||
{{ editSaving ? 'Salvando...' : 'Salvar' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DELETE MODAL -->
|
||||
<div *ngIf="deleteOpen" class="modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg danger-soft"><i class="bi bi-trash"></i></span> Remover Faturamento
|
||||
</div>
|
||||
<button class="btn btn-sm btn-icon" (click)="cancelDelete()"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="modal-body modern-body bg-light-gray">
|
||||
<div class="confirm-delete">
|
||||
<div class="confirm-icon"><i class="bi bi-trash"></i></div>
|
||||
<p class="mb-0">Confirma a exclusão do registro <strong>{{ deleteTarget?.cliente }}</strong>?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer p-3 text-end border-top bg-white">
|
||||
<button class="btn btn-glass btn-sm me-2" (click)="cancelDelete()">Cancelar</button>
|
||||
<button class="btn btn-danger btn-sm" (click)="confirmDelete()">Excluir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,22 @@
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-glass {
|
||||
border-radius: 12px;
|
||||
font-weight: 900;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(3, 15, 170, 0.24);
|
||||
color: var(--blue);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
.filters-row {
|
||||
display: flex;
|
||||
|
|
@ -322,7 +338,7 @@
|
|||
}
|
||||
|
||||
.search-group {
|
||||
max-width: 360px;
|
||||
max-width: 270px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
|
@ -671,7 +687,16 @@
|
|||
.th-item .th-content { justify-content: center; }
|
||||
|
||||
/* ACTIONS */
|
||||
.action-group { display: flex; justify-content: center; gap: 6px; }
|
||||
.actions-col { min-width: 152px; }
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
|
|
@ -687,6 +712,8 @@
|
|||
|
||||
&:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); }
|
||||
&.success:hover { color: var(--success-text); background: var(--success-bg); }
|
||||
&.primary:hover { color: var(--blue); background: rgba(3, 15, 170, 0.1); }
|
||||
&.danger:hover { color: #dc3545; background: rgba(220, 53, 69, 0.12); }
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
|
|
@ -753,8 +780,9 @@
|
|||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(900px, 100%);
|
||||
width: min(850px, 100%);
|
||||
max-height: 90vh;
|
||||
min-height: 0;
|
||||
animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
|
|
@ -781,27 +809,148 @@
|
|||
}
|
||||
|
||||
.icon-bg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: rgba(3, 15, 170, 0.12);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: rgba(3, 15, 170, 0.1);
|
||||
color: var(--blue);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.7), 0 8px 18px rgba(3, 15, 170, 0.18);
|
||||
font-size: 1rem;
|
||||
font-size: 16px;
|
||||
|
||||
&.success { background: var(--success-bg); color: var(--success-text); }
|
||||
&.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
|
||||
&.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
|
||||
&.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); }
|
||||
}
|
||||
|
||||
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } }
|
||||
}
|
||||
|
||||
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-body { padding: 24px; overflow-y: auto; flex: 1; min-height: 0; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-body .box-body { overflow: visible; }
|
||||
.modal-footer { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.modal-card { border-radius: 16px; }
|
||||
.modal-header { padding: 12px 16px; }
|
||||
.modal-body { padding: 16px; }
|
||||
}
|
||||
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
||||
|
||||
.edit-sections { display: grid; gap: 12px; }
|
||||
|
||||
.edit-sections details.detail-box {
|
||||
border: 1px solid rgba(17, 18, 20, 0.08);
|
||||
box-shadow: 0 8px 22px rgba(17, 18, 20, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
summary.box-header {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid rgba(17, 18, 20, 0.08);
|
||||
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
|
||||
&::-webkit-details-marker { display: none; }
|
||||
}
|
||||
|
||||
.transition-icon { transition: transform 0.25s ease, color 0.25s ease; color: var(--muted); }
|
||||
details[open] .transition-icon { transform: rotate(180deg); color: var(--brand); }
|
||||
|
||||
.header-vivo {
|
||||
color: #b91f9b;
|
||||
background: linear-gradient(135deg, rgba(227, 61, 207, 0.14), rgba(248, 250, 252, 0.96));
|
||||
}
|
||||
|
||||
.header-line {
|
||||
color: var(--blue);
|
||||
background: linear-gradient(135deg, rgba(3, 15, 170, 0.1), rgba(248, 250, 252, 0.96));
|
||||
}
|
||||
|
||||
.vivo-border { border-top: 3px solid rgba(227, 61, 207, 0.45); }
|
||||
.line-border { border-top: 3px solid rgba(3, 15, 170, 0.45); }
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&.span-2 { grid-column: span 2; }
|
||||
|
||||
label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(17, 18, 20, 0.64);
|
||||
}
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(17, 18, 20, 0.15);
|
||||
background-color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
|
||||
|
||||
&:hover { border-color: rgba(17, 18, 20, 0.38); }
|
||||
&:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
border: 1px solid rgba(220, 53, 69, 0.16);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
p { font-weight: 700; color: rgba(17, 18, 20, 0.85); }
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: #dc3545;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* detalhes e comparativo (mantidos) */
|
||||
.details-dashboard {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import {
|
|||
BillingSortBy,
|
||||
SortDir,
|
||||
TipoCliente,
|
||||
TipoFiltro
|
||||
TipoFiltro,
|
||||
BillingUpdateRequest
|
||||
} from '../../services/billing';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
|
||||
interface BillingClientGroup {
|
||||
cliente: string;
|
||||
|
|
@ -48,7 +50,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
constructor(
|
||||
@Inject(PLATFORM_ID) private platformId: object,
|
||||
private billing: BillingService,
|
||||
private cdr: ChangeDetectorRef
|
||||
private cdr: ChangeDetectorRef,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
||||
loading = false;
|
||||
|
|
@ -92,6 +95,14 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
compareOpen = false;
|
||||
detailData: BillingItem | null = null;
|
||||
compareData: BillingItem | null = null;
|
||||
editOpen = false;
|
||||
editSaving = false;
|
||||
editModel: BillingItem | null = null;
|
||||
editingId: string | null = null;
|
||||
deleteOpen = false;
|
||||
deleteTarget: BillingItem | null = null;
|
||||
|
||||
isAdmin = false;
|
||||
|
||||
private searchTimer: any = null;
|
||||
|
||||
|
|
@ -120,20 +131,21 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onDocumentKeydown(ev: KeyboardEvent) {
|
||||
onDocumentKeydown(ev: Event) {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
const keyboard = ev as KeyboardEvent;
|
||||
if (keyboard.key === 'Escape') {
|
||||
if (this.anyModalOpen()) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
keyboard.preventDefault();
|
||||
keyboard.stopPropagation();
|
||||
this.closeAllModals();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.showClientMenu) {
|
||||
this.showClientMenu = false;
|
||||
ev.stopPropagation();
|
||||
keyboard.stopPropagation();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +159,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
this.initAnimations();
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
|
||||
setTimeout(() => {
|
||||
this.refreshData(true);
|
||||
|
|
@ -165,7 +178,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
// Helpers
|
||||
// --------------------------
|
||||
private anyModalOpen(): boolean {
|
||||
return !!(this.detailOpen || this.compareOpen);
|
||||
return !!(this.detailOpen || this.compareOpen || this.editOpen || this.deleteOpen);
|
||||
}
|
||||
|
||||
closeAllModals() {
|
||||
|
|
@ -173,6 +186,11 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
this.compareOpen = false;
|
||||
this.detailData = null;
|
||||
this.compareData = null;
|
||||
this.editOpen = false;
|
||||
this.editModel = null;
|
||||
this.editingId = null;
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +222,24 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
private buildGlobalSearchBlob(row: BillingItem): string {
|
||||
const parts = [
|
||||
row.tipo,
|
||||
row.item,
|
||||
row.cliente,
|
||||
row.qtdLinhas,
|
||||
row.franquiaVivo,
|
||||
row.valorContratoVivo,
|
||||
row.franquiaLine,
|
||||
row.valorContratoLine,
|
||||
row.lucro,
|
||||
row.aparelho,
|
||||
row.formaPagamento,
|
||||
];
|
||||
|
||||
return this.normalizeText(parts.join(' '));
|
||||
}
|
||||
|
||||
private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean {
|
||||
if (filtro === 'ALL') return true;
|
||||
|
||||
|
|
@ -475,14 +511,9 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
arr = arr.filter((r) => set.has(this.normalizeText(r.cliente)));
|
||||
}
|
||||
|
||||
const term = (this.searchTerm ?? '').trim().toLowerCase();
|
||||
const term = this.normalizeText(this.searchTerm);
|
||||
if (term) {
|
||||
arr = arr.filter((r) => {
|
||||
const cliente = (r.cliente ?? '').toLowerCase();
|
||||
const aparelho = (r.aparelho ?? '').toLowerCase();
|
||||
const forma = (r.formaPagamento ?? '').toLowerCase();
|
||||
return cliente.includes(term) || aparelho.includes(term) || forma.includes(term);
|
||||
});
|
||||
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
|
||||
}
|
||||
|
||||
// KPIs
|
||||
|
|
@ -617,8 +648,17 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
// --------------------------
|
||||
onDetalhes(r: BillingItem) {
|
||||
this.detailOpen = true;
|
||||
this.detailData = r;
|
||||
this.cdr.detectChanges();
|
||||
this.detailData = null;
|
||||
this.billing.getById(r.id).subscribe({
|
||||
next: (data) => {
|
||||
this.detailData = data ?? r;
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: () => {
|
||||
this.detailData = r;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onComparativo(r: BillingItem) {
|
||||
|
|
@ -626,4 +666,86 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
|||
this.compareData = r;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
onEditar(r: BillingItem) {
|
||||
if (!this.isAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editOpen = true;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
closeEdit() {
|
||||
this.editOpen = false;
|
||||
this.editModel = null;
|
||||
this.editingId = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
onDelete(r: BillingItem) {
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
cancelDelete() {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
confirmDelete() {
|
||||
if (!this.deleteTarget) 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<section class="geral-page" (click)="closeClientDropdown()">
|
||||
<section class="geral-page" (click)="closeFilterDropdowns()">
|
||||
<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>
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="btn btn-glass btn-sm"
|
||||
*ngIf="isAdmin"
|
||||
(click)="onImportExcel()"
|
||||
[disabled]="loading">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> Importar Dados Excel
|
||||
|
|
@ -121,40 +122,125 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-filter-wrap" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-client-filter btn-additional-filter"
|
||||
[class.has-selection]="hasAdditionalFiltersApplied"
|
||||
(click)="toggleAdditionalMenu()"
|
||||
[disabled]="loading">
|
||||
|
||||
<ng-container *ngIf="!hasAdditionalFiltersApplied">
|
||||
<i class="bi bi-sliders2-vertical me-2"></i>
|
||||
<span>Adicionais</span>
|
||||
<i class="bi bi-chevron-down ms-2 small"></i>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="hasAdditionalFiltersApplied">
|
||||
<div class="chips-container">
|
||||
<span class="client-chip">
|
||||
{{ additionalModeLabel }}
|
||||
</span>
|
||||
<span *ngFor="let label of additionalSelectedLabels" class="client-chip">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<i class="bi bi-chevron-down ms-1 small text-muted"></i>
|
||||
</ng-container>
|
||||
</button>
|
||||
|
||||
<div class="client-dropdown additional-dropdown" *ngIf="showAdditionalMenu">
|
||||
<div class="additional-dropdown-section">
|
||||
<div class="additional-dropdown-title">Modo</div>
|
||||
<div class="additional-mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="additional-mode-btn"
|
||||
[class.active]="additionalMode === 'ALL'"
|
||||
(click)="setAdditionalMode('ALL')"
|
||||
[disabled]="loading">
|
||||
Todos os adicionais
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="additional-mode-btn"
|
||||
[class.active]="additionalMode === 'WITH'"
|
||||
(click)="setAdditionalMode('WITH')"
|
||||
[disabled]="loading">
|
||||
Com adicionais
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="additional-mode-btn"
|
||||
[class.active]="additionalMode === 'WITHOUT'"
|
||||
(click)="setAdditionalMode('WITHOUT')"
|
||||
[disabled]="loading">
|
||||
Sem adicionais
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-dropdown-section">
|
||||
<div class="additional-dropdown-title">Serviços</div>
|
||||
<div class="additional-services-chips">
|
||||
<button
|
||||
type="button"
|
||||
class="additional-chip-btn"
|
||||
*ngFor="let svc of additionalServiceOptions"
|
||||
[class.active]="isAdditionalServiceSelected(svc.key)"
|
||||
(click)="toggleAdditionalService(svc.key)"
|
||||
[disabled]="loading">
|
||||
{{ svc.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-dropdown-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="additional-chip-btn clear"
|
||||
(click)="clearAdditionalFilters()"
|
||||
[disabled]="loading">
|
||||
Limpar filtros adicionais
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="geral-kpis mt-4 animate-fade-in" *ngIf="isGroupMode">
|
||||
<div class="kpi">
|
||||
<span class="lbl">Total Clientes</span>
|
||||
<span class="val">
|
||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
|
||||
<span class="val val-loading" *ngIf="isKpiLoading">
|
||||
<span class="spinner-border spinner-border-sm text-brand"></span>
|
||||
</span>
|
||||
<span class="val" *ngIf="!isKpiLoading">{{ kpiTotalClientes || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<span class="lbl">Total Linhas</span>
|
||||
<span class="val">
|
||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
|
||||
<span class="val val-loading" *ngIf="isKpiLoading">
|
||||
<span class="spinner-border spinner-border-sm text-brand"></span>
|
||||
</span>
|
||||
<span class="val" *ngIf="!isKpiLoading">{{ kpiTotalLinhas || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<span class="lbl text-success">Ativas</span>
|
||||
<span class="val">
|
||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||
<span *ngIf="!loadingKpis">{{ kpiAtivas || 0 }}</span>
|
||||
<span class="val val-loading" *ngIf="isKpiLoading">
|
||||
<span class="spinner-border spinner-border-sm text-brand"></span>
|
||||
</span>
|
||||
<span class="val" *ngIf="!isKpiLoading">{{ kpiAtivas || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi">
|
||||
<span class="lbl text-danger">Bloqueadas</span>
|
||||
<span class="val">
|
||||
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
|
||||
<span *ngIf="!loadingKpis">{{ kpiBloqueadas || 0 }}</span>
|
||||
<span class="val val-loading" *ngIf="isKpiLoading">
|
||||
<span class="spinner-border spinner-border-sm text-brand"></span>
|
||||
</span>
|
||||
<span class="val" *ngIf="!isKpiLoading">{{ kpiBloqueadas || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -204,14 +290,14 @@
|
|||
<div class="group-header" (click)="toggleGroup(group.cliente)">
|
||||
<div class="group-info">
|
||||
<h6 class="mb-0 fw-bold text-dark">{{ group.cliente }}</h6>
|
||||
<div class="group-badges">
|
||||
<span class="badge-pill total">{{ group.totalLinhas }} Linhas</span>
|
||||
<span class="badge-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} Ativas</span>
|
||||
<span class="badge-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} Bloq.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
|
||||
<div class="group-tags">
|
||||
<span class="tag-pill">{{ group.totalLinhas }} linhas</span>
|
||||
<span class="tag-pill active" *ngIf="group.ativos > 0">{{ group.ativos }} ativas</span>
|
||||
<span class="tag-pill blocked" *ngIf="group.bloqueados > 0">{{ group.bloqueados }} bloqueadas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-toggle-icon"><i class="bi bi-chevron-down"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="group-body" *ngIf="expandedGroup === group.cliente">
|
||||
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
|
||||
|
|
@ -253,7 +339,7 @@
|
|||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -370,7 +456,7 @@
|
|||
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -428,9 +514,8 @@
|
|||
<div
|
||||
*ngIf="createOpen"
|
||||
#createModal
|
||||
class="modal-card modal-lg"
|
||||
class="modal-card modal-lg modal-create"
|
||||
(click)="$event.stopPropagation()"
|
||||
style="width: 1100px; max-width: 95vw;"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
|
|
@ -490,9 +575,28 @@
|
|||
<input class="form-control form-control-sm bg-light fst-italic text-muted" value="Gerado ao Salvar" readonly title="O ID será gerado automaticamente pelo sistema" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Empresa (Conta) <span class="text-danger">*</span></label>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="contaEmpresaOptions"
|
||||
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
|
||||
[(ngModel)]="createModel.contaEmpresa"
|
||||
(ngModelChange)="onContaEmpresaChange(false)"
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Conta <span class="text-danger">*</span></label>
|
||||
<app-select class="form-select" size="sm" [options]="contaOptions" [(ngModel)]="createModel.conta"></app-select>
|
||||
<app-select
|
||||
class="form-select"
|
||||
size="sm"
|
||||
[options]="contaOptionsForCreate"
|
||||
[disabled]="!createModel.contaEmpresa"
|
||||
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
|
||||
[(ngModel)]="createModel.conta"
|
||||
></app-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
|
|
@ -505,6 +609,11 @@
|
|||
<input class="form-control form-control-sm" [(ngModel)]="createModel.chip" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Tipo de Chip</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" />
|
||||
</div>
|
||||
|
||||
<div class="form-field span-2">
|
||||
<label>Usuário da Linha</label>
|
||||
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" />
|
||||
|
|
@ -513,7 +622,7 @@
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<details class="detail-box mt-3">
|
||||
<details class="detail-box mt-3" open>
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
|
|
@ -551,7 +660,7 @@
|
|||
<div class="form-grid">
|
||||
<div class="form-field span-2">
|
||||
<label>Plano Contrato <span class="text-danger">*</span></label>
|
||||
<app-select class="form-select" size="sm" [options]="planOptions" [(ngModel)]="createModel.planoContrato"></app-select>
|
||||
<app-select class="form-select" size="sm" [options]="planOptions" [(ngModel)]="createModel.planoContrato" (ngModelChange)="onPlanoChange(false)"></app-select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
|
|
@ -572,7 +681,7 @@
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<details class="detail-box mt-3">
|
||||
<details class="detail-box mt-3" open>
|
||||
<summary class="box-header">
|
||||
<span><i class="bi bi-calendar-event me-2"></i> Datas Importantes</span>
|
||||
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
|
||||
|
|
@ -594,6 +703,16 @@
|
|||
<label>Data de Bloqueio</label>
|
||||
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataBloqueio" />
|
||||
</div>
|
||||
|
||||
<div class="form-field span-2">
|
||||
<label>Dt. Efetivação Serviço <span class="text-danger">*</span></label>
|
||||
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtEfetivacaoServico" required />
|
||||
</div>
|
||||
|
||||
<div class="form-field span-2">
|
||||
<label>Dt. Término Fidelização <span class="text-danger">*</span></label>
|
||||
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtTerminoFidelizacao" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -615,6 +734,7 @@
|
|||
<div class="form-field"><label>News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoNewsPlus" (change)="onFinancialChange(false)" /></div>
|
||||
<div class="form-field"><label>Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoTravelMundo" (change)="onFinancialChange(false)" /></div>
|
||||
<div class="form-field"><label>Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoGestaoDispositivo" (change)="onFinancialChange(false)" /></div>
|
||||
<div class="form-field"><label>Vivo Sync</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoSync" (change)="onFinancialChange(false)" /></div>
|
||||
<div class="form-field"><label class="text-vivo fw-bold">Total Vivo (Auto)</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="createModel.valorContratoVivo" readonly /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -698,6 +818,10 @@
|
|||
<span class="lbl">Chip (ICCID)</span>
|
||||
<span class="val small-text">{{ detailData.chip || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Tipo de Chip</span>
|
||||
<span class="val">{{ detailData.tipoDeChip || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -744,6 +868,14 @@
|
|||
<span class="lbl text-danger">Data Bloqueio</span>
|
||||
<span class="val">{{ formatDateBr(detailData.dataBloqueio) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Efetivação Serviço</span>
|
||||
<span class="val">{{ formatDateBr(detailData.dtEfetivacaoServico) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Término Fidelização</span>
|
||||
<span class="val">{{ formatDateBr(detailData.dtTerminoFidelizacao) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -817,6 +949,7 @@
|
|||
<div class="row-item"><span>Vivo News+</span> <strong>{{ formatMoney(financeData.vivoNewsPlus) }}</strong></div>
|
||||
<div class="row-item"><span>Travel Mundo</span> <strong>{{ formatMoney(financeData.vivoTravelMundo) }}</strong></div>
|
||||
<div class="row-item"><span>Gestão Disp.</span> <strong>{{ formatMoney(financeData.vivoGestaoDispositivo) }}</strong></div>
|
||||
<div class="row-item"><span>Vivo Sync</span> <strong>{{ formatMoney(financeData.vivoSync) }}</strong></div>
|
||||
<div class="divider"></div>
|
||||
<div class="row-item total"><span>Total Vivo</span> <strong>{{ formatMoney(financeData.valorContratoVivo) }}</strong></div>
|
||||
</div>
|
||||
|
|
@ -882,9 +1015,11 @@
|
|||
<div class="box-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-field"><label>Item</label><input class="form-control form-control-sm bg-light" type="number" [(ngModel)]="editModel.item" disabled title="O ID não pode ser alterado" /></div>
|
||||
<div class="form-field"><label>Empresa (Conta)</label><app-select class="form-select" size="sm" [options]="contaEmpresaOptionsForEdit" [(ngModel)]="editModel.contaEmpresa" (ngModelChange)="onContaEmpresaChange(true)"></app-select></div>
|
||||
<div class="form-field"><label>Conta</label><app-select class="form-select" size="sm" [options]="contaOptionsForEdit" [(ngModel)]="editModel.conta"></app-select></div>
|
||||
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
|
||||
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div>
|
||||
<div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div>
|
||||
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
|
||||
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
|
||||
</div>
|
||||
|
|
@ -895,7 +1030,7 @@
|
|||
<summary class="box-header"><span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Plano</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>Plano Contrato</label><app-select class="form-select" size="sm" [options]="planOptionsForEdit" [(ngModel)]="editModel.planoContrato"></app-select></div>
|
||||
<div class="form-field span-2"><label>Plano Contrato</label><app-select class="form-select" size="sm" [options]="planOptionsForEdit" [(ngModel)]="editModel.planoContrato" (ngModelChange)="onPlanoChange(true)"></app-select></div>
|
||||
<div class="form-field"><label>Venc. da Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.vencConta" /></div>
|
||||
<div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="editModel.modalidade" /></div>
|
||||
</div>
|
||||
|
|
@ -910,6 +1045,8 @@
|
|||
<div class="form-field"><label>Data do Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataBloqueio" /></div>
|
||||
<div class="form-field"><label>Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaOpera" /></div>
|
||||
<div class="form-field"><label>Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaCliente" /></div>
|
||||
<div class="form-field"><label>Dt. Efetivação Serviço</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dtEfetivacaoServico" /></div>
|
||||
<div class="form-field"><label>Dt. Término Fidelização</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dtTerminoFidelizacao" /></div>
|
||||
<div class="form-field"><label>Skil</label><app-select class="form-select" size="sm" [options]="skilOptionsForEdit" [(ngModel)]="editModel.skil"></app-select></div>
|
||||
<div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cedente" /></div>
|
||||
<div class="form-field span-2"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="editModel.solicitante" /></div>
|
||||
|
|
@ -928,6 +1065,7 @@
|
|||
<div class="form-field"><label>Vivo News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoNewsPlus" (change)="onFinancialChange(true)" /></div>
|
||||
<div class="form-field"><label>Vivo Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoTravelMundo" (change)="onFinancialChange(true)" /></div>
|
||||
<div class="form-field"><label>Vivo Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoGestaoDispositivo" (change)="onFinancialChange(true)" /></div>
|
||||
<div class="form-field"><label>Vivo Sync</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoSync" (change)="onFinancialChange(true)" /></div>
|
||||
<div class="form-field"><label class="text-vivo fw-bold">Valor Contrato Vivo</label><input class="form-control form-control-sm fw-bold border-vivo bg-light" type="number" step="0.01" [(ngModel)]="editModel.valorContratoVivo" readonly /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -963,4 +1101,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@
|
|||
/* 2. LAYOUT DA PÁGINA (Vertical Destravado) */
|
||||
/* ========================================================== */
|
||||
.geral-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 12px var(--page-bottom-gap);
|
||||
min-height: 100dvh;
|
||||
padding: var(--page-top-gap) 12px var(--page-bottom-gap);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow-y: auto; /* Scroll na janela */
|
||||
overflow: visible;
|
||||
background:
|
||||
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%),
|
||||
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
max-width: 1100px; /* Largura controlada */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: var(--page-top-gap);
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--page-bottom-gap);
|
||||
margin-left: auto; margin-right: auto;
|
||||
}
|
||||
|
|
@ -138,9 +138,123 @@
|
|||
.dropdown-list { overflow-y: auto; max-height: 300px; }
|
||||
.dropdown-item-custom { padding: 10px 16px; font-size: 0.85rem; color: var(--text); cursor: pointer; border-bottom: 1px solid rgba(0,0,0,0.03); transition: background 0.1s; &:hover { background: rgba(227,61,207,0.05); color: var(--brand); font-weight: 600; } &.selected { background: rgba(227, 61, 207, 0.08); color: var(--brand); font-weight: 700; } }
|
||||
|
||||
.additional-filter-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-additional-filter {
|
||||
min-width: 160px;
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.additional-dropdown {
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-height: 460px;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.additional-dropdown-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.additional-dropdown-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(17, 18, 20, 0.6);
|
||||
}
|
||||
|
||||
.additional-dropdown-footer {
|
||||
padding-top: 4px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.additional-mode-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.additional-mode-btn {
|
||||
border: 1px solid rgba(17, 18, 20, 0.12);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.additional-services-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.additional-chip-btn {
|
||||
border: 1px solid rgba(17, 18, 20, 0.12);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
background: rgba(227, 61, 207, 0.12);
|
||||
}
|
||||
|
||||
&.clear {
|
||||
color: var(--blue);
|
||||
border-color: rgba(3, 15, 170, 0.18);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
|
||||
.kpi { background: rgba(255,255,255,0.7); border: 1px solid rgba(17,18,20,0.08); border-radius: 16px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; backdrop-filter: blur(8px); transition: transform 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.02); &:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(227, 61, 207, 0.1); background: #fff; border-color: var(--brand); } .lbl { font-size: 0.72rem; font-weight: 900; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); &.text-success { color: var(--success-text) !important; } &.text-danger { color: var(--danger-text) !important; } } .val { font-size: 1.25rem; font-weight: 950; color: var(--text); } }
|
||||
.kpi .val-loading { font-size: 0.86rem; font-weight: 900; color: var(--muted); display: inline-flex; align-items: center; }
|
||||
|
||||
/* Insights */
|
||||
|
||||
/* Controls */
|
||||
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
|
|
@ -166,6 +280,10 @@
|
|||
.group-info { display: flex; flex-direction: column; gap: 6px; }
|
||||
.group-badges { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.badge-pill { font-size: 0.7rem; padding: 4px 10px; border-radius: 999px; font-weight: 800; text-transform: uppercase; &.total { background: rgba(3,15,170,0.1); color: var(--blue); } &.ok { background: var(--success-bg); color: var(--success-text); } }
|
||||
.group-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag-pill { font-size: 0.65rem; padding: 4px 8px; border-radius: 999px; font-weight: 800; text-transform: uppercase; background: rgba(3,15,170,0.08); color: var(--blue); border: 1px solid rgba(3,15,170,0.16); }
|
||||
.tag-pill.active { background: var(--success-bg); color: var(--success-text); border-color: rgba(25,135,84,0.22); }
|
||||
.tag-pill.blocked { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(220,53,69,0.22); }
|
||||
.group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; }
|
||||
.client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); }
|
||||
.group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
|
|
@ -209,6 +327,7 @@
|
|||
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
|
||||
.modal-body .box-body { overflow: visible; }
|
||||
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
||||
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
|
||||
|
||||
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
|
||||
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
:host {
|
||||
--brand: #E33DCF;
|
||||
--blue: #030FAA;
|
||||
--text: #111214;
|
||||
--muted: rgba(17, 18, 20, 0.65);
|
||||
|
||||
--success-bg: rgba(25, 135, 84, 0.12);
|
||||
--success-text: #198754;
|
||||
--info-bg: rgba(3, 15, 170, 0.1);
|
||||
--info-text: #030FAA;
|
||||
--danger-bg: rgba(220, 53, 69, 0.12);
|
||||
--danger-text: #dc3545;
|
||||
|
||||
--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;
|
||||
}
|
||||
|
||||
.historico-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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%);
|
||||
|
||||
&::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-geral-responsive {
|
||||
width: 100%;
|
||||
max-width: 1400px !important;
|
||||
width: 98% !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
|
||||
.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);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 80vh;
|
||||
&::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;
|
||||
}
|
||||
}
|
||||
|
||||
.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));
|
||||
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;
|
||||
gap: 16px;
|
||||
.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: 26px;
|
||||
font-weight: 950;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--text);
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(17, 18, 20, 0.65);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(17, 18, 20, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08);
|
||||
}
|
||||
|
||||
.filters-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
color: rgba(17, 18, 20, 0.82);
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: rgba(17, 18, 20, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.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: rgba(17, 18, 20, 0.5);
|
||||
padding-left: 14px;
|
||||
padding-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border: none;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
padding: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; }
|
||||
&:focus { outline: none; }
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(17, 18, 20, 0.45);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover { color: #dc3545; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-ghost {
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(3, 15, 170, 0.18);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
color: rgba(17, 18, 20, 0.86);
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.select-wrapper { position: relative; display: inline-block; min-width: 90px; }
|
||||
|
||||
.select-glass {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(17, 18, 20, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--blue);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.geral-body {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-modern {
|
||||
width: 100%;
|
||||
min-width: 1200px !important;
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.table-modern th:nth-child(5),
|
||||
.table-modern td:nth-child(5) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-modern th:nth-child(2),
|
||||
.table-modern td:nth-child(2) {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.table-row-item.expanded {
|
||||
background: rgba(227, 61, 207, 0.06);
|
||||
}
|
||||
|
||||
.td-clip {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.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);
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: rgba(17, 18, 20, 0.55);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entity-label {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(17, 18, 20, 0.5);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
border: none;
|
||||
background: rgba(3, 15, 170, 0.08);
|
||||
color: var(--blue);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
&:hover { transform: translateY(-1px); background: rgba(227, 61, 207, 0.12); color: var(--brand); }
|
||||
}
|
||||
|
||||
.badge-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.action-create {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.action-update {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.action-default {
|
||||
background: rgba(17,18,20,0.08);
|
||||
color: rgba(17,18,20,0.7);
|
||||
}
|
||||
|
||||
.details-row td {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0 12px 16px;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(17, 18, 20, 0.08);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.changes-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
border: 1px solid rgba(17, 18, 20, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.change-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.change-field {
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.change-type {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.change-added { background: var(--success-bg); color: var(--success-text); }
|
||||
.change-removed { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.change-modified { background: var(--info-bg); color: var(--info-text); }
|
||||
|
||||
.change-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(17, 18, 20, 0.7);
|
||||
}
|
||||
|
||||
.change-values .old {
|
||||
color: rgba(17, 18, 20, 0.6);
|
||||
}
|
||||
|
||||
.change-values .new {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(17, 18, 20, 0.04);
|
||||
border: 1px dashed rgba(17, 18, 20, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(17, 18, 20, 0.6);
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tech-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: rgba(17, 18, 20, 0.55);
|
||||
}
|
||||
|
||||
.tech-value {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.geral-footer {
|
||||
padding: 12px 20px 18px;
|
||||
border-top: 1px solid rgba(17,18,20,0.06);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.text-brand { color: var(--brand) !important; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filters-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filters-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.filters-grid { grid-template-columns: 1fr; }
|
||||
.search-group { width: 100%; max-width: 100%; }
|
||||
.entity-cell { flex-direction: column; align-items: flex-start; }
|
||||
.expand-btn { align-self: flex-end; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,6 @@
|
|||
<span class="checkmark"></span>
|
||||
Lembrar de mim
|
||||
</label>
|
||||
<a href="#" class="forgot-link">Esqueceu a senha?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary-login" [disabled]="isSubmitting || loginForm.invalid">
|
||||
|
|
|
|||
|
|
@ -69,18 +69,6 @@ export class LoginComponent {
|
|||
}
|
||||
}
|
||||
|
||||
private saveToken(token: string) {
|
||||
// ✅ SSR-safe
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
// evita token antigo conflitar
|
||||
localStorage.removeItem('token');
|
||||
|
||||
// Se quiser implementar a lógica de "Manter conectado", pode verificar o rememberMe aqui
|
||||
// mas mantive a lógica original simples:
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
console.log('🚀 Iniciando login...');
|
||||
this.apiError = '';
|
||||
|
|
@ -94,7 +82,10 @@ export class LoginComponent {
|
|||
this.isSubmitting = true;
|
||||
const v = this.loginForm.value;
|
||||
|
||||
this.authService.login({ email: v.username, password: v.password }).subscribe({
|
||||
this.authService.login(
|
||||
{ email: v.username, password: v.password },
|
||||
{ rememberMe: !!v.rememberMe }
|
||||
).subscribe({
|
||||
next: (res: any) => { // Use 'any' temporariamente para ver tudo que vem
|
||||
console.log('✅ Resposta da API:', res);
|
||||
this.isSubmitting = false;
|
||||
|
|
@ -109,8 +100,7 @@ export class LoginComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log('🔑 Token encontrado. Salvando...');
|
||||
this.saveToken(token);
|
||||
this.authService.setToken(token, !!v.rememberMe);
|
||||
|
||||
const payload = this.authService.getTokenPayload();
|
||||
const tenantId = payload?.['tenantId'] ?? payload?.['tenant'] ?? payload?.['TenantId'];
|
||||
|
|
|
|||
|
|
@ -24,6 +24,39 @@
|
|||
Arquivadas / Lidas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bulk-actions-bar" *ngIf="!loading && !error">
|
||||
<div class="bulk-left">
|
||||
<label class="select-all" *ngIf="filter !== 'lidas' && 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>
|
||||
</div>
|
||||
|
||||
<div class="state-container" *ngIf="loading">
|
||||
|
|
@ -46,11 +79,6 @@
|
|||
</div>
|
||||
|
||||
<div class="notif-list" *ngIf="!loading && !error && filteredNotifications.length > 0">
|
||||
|
||||
<div class="list-header-actions">
|
||||
<span>Mostrando {{ filteredNotifications.length }} notificações</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="list-item"
|
||||
*ngFor="let n of filteredNotifications"
|
||||
|
|
@ -60,6 +88,11 @@
|
|||
>
|
||||
<div class="status-strip"></div>
|
||||
|
||||
<label class="item-select" *ngIf="filter !== 'lidas'">
|
||||
<input type="checkbox" [checked]="isSelected(n)" (change)="toggleSelection(n)" />
|
||||
<span></span>
|
||||
</label>
|
||||
|
||||
<div class="item-icon">
|
||||
<i class="bi" [class.bi-x-circle-fill]="n.tipo === 'Vencido'" [class.bi-clock-fill]="n.tipo === 'AVencer'"></i>
|
||||
</div>
|
||||
|
|
@ -68,25 +101,34 @@
|
|||
<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]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
|
||||
{{ n.tipo === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
||||
</span>
|
||||
</h4>
|
||||
<span class="item-time">
|
||||
{{ n.referenciaData ? (n.referenciaData | date:'dd/MM/yyyy') : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="item-details">
|
||||
<strong>Cliente:</strong> {{ n.cliente || '-' }} • <strong>Usuário:</strong> {{ n.usuario || '-' }}
|
||||
</p>
|
||||
|
||||
<p class="item-message" *ngIf="n.tipo === 'Vencido'">
|
||||
A vigência desta linha expirou. Verifique a renovação imediatamente.
|
||||
</p>
|
||||
<p class="item-message" *ngIf="n.tipo === 'AVencer'">
|
||||
A vigência irá expirar em breve. Programe-se.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,74 @@ $border: #e5e7eb;
|
|||
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;
|
||||
|
|
@ -93,10 +161,7 @@ $border: #e5e7eb;
|
|||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
.list-header-actions {
|
||||
font-size: 12px; font-weight: 600; color: $text-secondary; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin-bottom: 8px; padding-left: 8px;
|
||||
}
|
||||
/* list-header-actions removido */
|
||||
|
||||
.list-item {
|
||||
background: $white;
|
||||
|
|
@ -125,6 +190,24 @@ $border: #e5e7eb;
|
|||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -141,33 +224,66 @@ $border: #e5e7eb;
|
|||
.item-content { flex: 1; min-width: 0; }
|
||||
|
||||
.content-top {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 16px; font-weight: 700; color: $text-main; margin: 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
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: 2px 6px; border-radius: 4px;
|
||||
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-time { font-size: 12px; color: $text-secondary; font-weight: 500; }
|
||||
|
||||
.item-details {
|
||||
font-size: 13px; color: $text-secondary; margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.item-message {
|
||||
font-size: 13px; color: $text-secondary; margin: 0; opacity: 0.8;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
margin-left: 12px; align-self: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export class Notificacoes implements OnInit {
|
|||
filter: 'todas' | 'vencidas' | 'aVencer' | 'lidas' = 'todas';
|
||||
loading = false;
|
||||
error = false;
|
||||
bulkLoading = false;
|
||||
exportLoading = false;
|
||||
selectedIds = new Set<string>();
|
||||
|
||||
constructor(private notificationsService: NotificationsService) {}
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ export class Notificacoes implements OnInit {
|
|||
|
||||
setFilter(value: 'todas' | 'vencidas' | 'aVencer' | 'lidas') {
|
||||
this.filter = value;
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
get filteredNotifications() {
|
||||
|
|
@ -49,6 +53,11 @@ export class Notificacoes implements OnInit {
|
|||
return this.notifications;
|
||||
}
|
||||
|
||||
formatDateLabel(date?: string | null): string {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
private loadNotifications() {
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
|
|
@ -65,6 +74,119 @@ export class Notificacoes implements OnInit {
|
|||
}
|
||||
|
||||
countByType(tipo: 'Vencido' | 'AVencer'): number {
|
||||
return this.notifications.filter(n => n.tipo === tipo && !n.lida).length;
|
||||
}
|
||||
return this.notifications.filter(n => n.tipo === tipo && !n.lida).length;
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
if (this.filter === 'lidas' || this.bulkLoading) return;
|
||||
this.bulkLoading = true;
|
||||
|
||||
const filterParam = this.getFilterParam();
|
||||
const ids = Array.from(this.selectedIds);
|
||||
this.notificationsService.markAllAsRead(filterParam, ids.length ? ids : undefined).subscribe({
|
||||
next: () => {
|
||||
const now = new Date().toISOString();
|
||||
this.notifications = this.notifications.map((n) => {
|
||||
if (ids.length ? ids.includes(n.id) : this.shouldMarkRead(n)) {
|
||||
return { ...n, lida: true, lidaEm: now };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
this.clearSelection();
|
||||
this.bulkLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.bulkLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exportNotifications() {
|
||||
if (this.filter === 'lidas' || this.exportLoading) return;
|
||||
this.exportLoading = true;
|
||||
|
||||
const filterParam = this.getFilterParam();
|
||||
const ids = Array.from(this.selectedIds);
|
||||
this.notificationsService.export(filterParam, ids.length ? ids : 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 n.tipo === 'AVencer';
|
||||
if (this.filter === 'vencidas') return n.tipo === 'Vencido';
|
||||
return false;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
<p>Gerencie permissões e status.</p>
|
||||
</div>
|
||||
<div class="list-actions">
|
||||
<input type="text" placeholder="Buscar por nome ou email" [(ngModel)]="search" (keyup.enter)="onSearch()" />
|
||||
<input type="text" placeholder="Pesquisar..." [(ngModel)]="search" (keyup.enter)="onSearch()" />
|
||||
<button type="button" class="btn-secondary" (click)="onSearch()">Buscar</button>
|
||||
<button type="button" class="btn-ghost" (click)="clearSearch()">Limpar</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ export class Register {
|
|||
this.isSubmitting = false;
|
||||
|
||||
// Se você não quer manter "logado" após cadastrar:
|
||||
localStorage.removeItem('token');
|
||||
this.authService.logout();
|
||||
|
||||
await this.showToast('Cadastro realizado com sucesso! Agora faça login para continuar.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 financeiros.</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>Performance financeira agrupada por modalidade de plano.</p>
|
||||
</div>
|
||||
<div class="hero-kpis">
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-lbl">Total Linhas</span>
|
||||
<strong class="kpi-val">{{ formatNumber(planosTotals?.totalLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-lbl">Valor Total</span>
|
||||
<strong class="kpi-val text-brand">{{ formatMoney(planosTotals?.valorTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<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">
|
||||
<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">
|
||||
<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..."
|
||||
[(ngModel)]="macrophonySearch"
|
||||
(ngModelChange)="onMacrophonySearch()" />
|
||||
</div>
|
||||
|
||||
<div class="tools-right">
|
||||
<label class="select-label">
|
||||
Exibir
|
||||
<select [(ngModel)]="macrophonyPageSize" (ngModelChange)="onMacrophonyPageSizeChange()">
|
||||
<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">CSV</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">
|
||||
<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">Valor Un.</th>
|
||||
<th class="text-right">Linhas</th>
|
||||
<th class="text-right">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">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
|
||||
<td class="text-right num-font font-bold">{{ 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">
|
||||
<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>Analise a rentabilidade e custos por cliente.</p>
|
||||
</div>
|
||||
<div class="hero-kpis">
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-lbl">Total Linhas</span>
|
||||
<strong class="kpi-val">{{ formatNumber(clientesTotals?.qtdLinhasTotal) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-lbl">Receita Line</span>
|
||||
<strong class="kpi-val">{{ formatMoney(clientesTotals?.valorContratoLine) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card highlight">
|
||||
<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>Top Clientes (Lucratividade)</h3>
|
||||
<p>Clientes ordenados pelo maior retorno financeiro.</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">
|
||||
<span class="kpi-lbl">Lucro Consolidado</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>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">
|
||||
<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..."
|
||||
[(ngModel)]="group.search"
|
||||
(ngModelChange)="onGroupedSearch(group)" />
|
||||
</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">CSV</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="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">Valor Un.</th>
|
||||
<th class="text-right">Total Linhas</th>
|
||||
<th class="text-right">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">{{ formatMoney(row.valorIndividualComSvas) }}</td>
|
||||
<td class="text-right num-font">{{ formatNumber(row.totalLinhas) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(row.valorTotal) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot *ngIf="macrophonyDetailGroup">
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Total deste grupo</td>
|
||||
<td class="text-right num-font">{{ formatNumber(macrophonyDetailGroup.totalLinhas) }}</td>
|
||||
<td class="text-right num-font">{{ formatMoney(macrophonyDetailGroup.valorTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,790 @@
|
|||
: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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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";
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
|
@ -76,7 +76,7 @@
|
|||
<span class="input-group-text">
|
||||
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" [class.text-brand]="loading"></i>
|
||||
</span>
|
||||
<input class="form-control" placeholder="Pesquisar (linha, ICCID, motivo, observação)..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
|
||||
<input class="form-control" placeholder="Pesquisar..." [(ngModel)]="searchTerm" (ngModelChange)="onSearch()" />
|
||||
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="searchTerm">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@
|
|||
.controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
|
||||
.search-group {
|
||||
max-width: 380px;
|
||||
max-width: 270px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@
|
|||
<h5 class="title">GESTÃO DE VIGÊNCIA</h5>
|
||||
<small class="subtitle">Controle de contratos e fidelização</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 justify-content-end"></div>
|
||||
<div class="header-actions d-flex gap-2 justify-content-end">
|
||||
<button *ngIf="isAdmin" class="btn btn-brand btn-sm" (click)="openCreate()">
|
||||
<i class="bi bi-plus-circle me-1"></i> Nova Vigência
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mureg-kpis mt-4 animate-fade-in" *ngIf="viewMode === 'groups'">
|
||||
|
|
@ -39,19 +43,25 @@
|
|||
<span class="lbl text-danger">Total Vencidos</span>
|
||||
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
|
||||
</div>
|
||||
<div class="kpi kpi-stack">
|
||||
<span class="lbl text-brand">Valor Total</span>
|
||||
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls mt-3 mb-2 d-flex flex-wrap gap-3 align-items-center justify-content-between">
|
||||
<div class="search-group flex-grow-1" style="max-width: 400px;">
|
||||
<div class="position-relative">
|
||||
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading" style="position: absolute; left: 14px; top: 10px; color: var(--muted);"></i>
|
||||
<input class="form-control ps-5" placeholder="Pesquisar cliente..." [(ngModel)]="search" (keyup.enter)="fetch(1)" [disabled]="loading">
|
||||
<button *ngIf="search" class="btn btn-link position-absolute end-0 top-0 text-muted" (click)="clearFilters()"><i class="bi bi-x-circle"></i></button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm search-group">
|
||||
<span class="input-group-text">
|
||||
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
|
||||
</span>
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Pesquisar..."
|
||||
[(ngModel)]="search"
|
||||
(ngModelChange)="onSearchChange()">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-clear"
|
||||
type="button"
|
||||
*ngIf="search"
|
||||
(click)="clearFilters()">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="page-size d-flex align-items-center gap-2">
|
||||
|
|
@ -109,7 +119,7 @@
|
|||
<th>EFETIVAÇÃO</th>
|
||||
<th>VENCIMENTO</th>
|
||||
<th class="text-end">TOTAL</th>
|
||||
<th style="min-width: 80px;">AÇÕES</th>
|
||||
<th class="actions-col">AÇÕES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -135,6 +145,8 @@
|
|||
<td>
|
||||
<div class="action-group justify-content-center">
|
||||
<button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button>
|
||||
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -164,52 +176,258 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div class="lg-backdrop" *ngIf="detailsOpen" (click)="closeDetails()"></div>
|
||||
<div class="lg-backdrop" *ngIf="detailsOpen || editOpen || deleteOpen || createOpen" (click)="closeDetails(); closeEdit(); cancelDelete(); closeCreate()"></div>
|
||||
|
||||
<div class="lg-modal" *ngIf="detailsOpen">
|
||||
<div class="lg-modal-card">
|
||||
<div class="modal-header d-flex justify-content-between align-items-center p-3 border-bottom">
|
||||
<h6 class="mb-0 fw-bold"><i class="bi bi-card-list me-2 text-brand"></i> Detalhes da Linha</h6>
|
||||
<button class="btn-close" (click)="closeDetails()"></button>
|
||||
<div class="lg-modal-card modal-lg" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg primary-soft"><i class="bi bi-card-list"></i></span>
|
||||
Detalhes da Vigência
|
||||
</div>
|
||||
<div class="modal-body p-4 bg-light-gray">
|
||||
<div class="form-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Cliente</small>
|
||||
<span class="fw-bold text-dark">{{ selectedRow?.cliente }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Linha</small>
|
||||
<span class="fw-black text-blue fs-5">{{ selectedRow?.linha }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Conta</small>
|
||||
<span>{{ selectedRow?.conta || '-' }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Usuário</small>
|
||||
<span>{{ selectedRow?.usuario || '-' }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column span-2" style="grid-column: span 2;">
|
||||
<small class="text-muted fw-bold text-uppercase">Plano</small>
|
||||
<span class="p-2 bg-white border rounded">{{ selectedRow?.planoContrato || '-' }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Efetivação</small>
|
||||
<span>{{ selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy' }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted fw-bold text-uppercase">Término</small>
|
||||
<span class="text-danger fw-bold">{{ selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy' }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column span-2 text-end pt-2 border-top">
|
||||
<small class="text-muted fw-bold text-uppercase">Valor Total</small>
|
||||
<span class="fw-black text-brand fs-4">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon" (click)="closeDetails()"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body bg-light-gray">
|
||||
<div class="details-dashboard">
|
||||
<div class="detail-box">
|
||||
<div class="box-header justify-content-center">
|
||||
<span><i class="bi bi-card-text me-2"></i> Informações da Linha</span>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-item span-2">
|
||||
<span class="lbl">Cliente</span>
|
||||
<span class="val">{{ selectedRow?.cliente || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Linha</span>
|
||||
<span class="val fw-black text-blue">{{ selectedRow?.linha || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Conta</span>
|
||||
<span class="val">{{ selectedRow?.conta || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item span-2">
|
||||
<span class="lbl">Usuário</span>
|
||||
<span class="val">{{ selectedRow?.usuario || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item span-2">
|
||||
<span class="lbl">Plano</span>
|
||||
<span class="val">{{ selectedRow?.planoContrato || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Efetivação</span>
|
||||
<span class="val">{{ selectedRow?.dtEfetivacaoServico ? (selectedRow?.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Término</span>
|
||||
<span class="val" [class.text-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
|
||||
{{ selectedRow?.dtTerminoFidelizacao ? (selectedRow?.dtTerminoFidelizacao | date:'dd/MM/yyyy') : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Situação</span>
|
||||
<span class="status-pill" [class.is-danger]="isVencido(selectedRow?.dtTerminoFidelizacao)">
|
||||
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="lbl">Valor Total</span>
|
||||
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer p-3 text-end border-top">
|
||||
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer p-3 text-end border-top">
|
||||
<button class="btn btn-glass btn-sm" (click)="closeDetails()">Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CREATE MODAL -->
|
||||
<div class="lg-modal" *ngIf="createOpen">
|
||||
<div class="lg-modal-card modal-xl create-modal" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span class="icon-bg primary-soft"><i class="bi bi-plus-circle"></i></span>
|
||||
Nova Vigência
|
||||
</div>
|
||||
<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"><label>Linha</label><input class="form-control form-control-sm" [(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"><label>Item (opcional)</label><input class="form-control form-control-sm" type="number" [(ngModel)]="createModel.item" /></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"><label>Linha</label><input class="form-control form-control-sm" [(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>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
--blue: #030FAA;
|
||||
--text: #111214;
|
||||
--muted: rgba(17, 18, 20, 0.65);
|
||||
--surface-soft: rgba(255, 255, 255, 0.7);
|
||||
--surface-hover: rgba(255, 255, 255, 0.94);
|
||||
--focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16);
|
||||
|
||||
--success-bg: rgba(25, 135, 84, 0.1);
|
||||
--success-text: #198754;
|
||||
|
|
@ -75,42 +78,107 @@
|
|||
|
||||
.title-badge {
|
||||
display: inline-flex; align-items: center; gap: 10px; padding: 6px 12px;
|
||||
border-radius: 999px; background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.7));
|
||||
border: 1px solid rgba(227, 61, 207, 0.22); font-size: 13px; font-weight: 800;
|
||||
box-shadow: 0 8px 20px rgba(17, 18, 20, 0.06);
|
||||
i { color: var(--brand); }
|
||||
}
|
||||
|
||||
.header-title { text-align: center; }
|
||||
.title { font-size: 1.5rem; font-weight: 950; margin: 0; letter-spacing: -0.5px; }
|
||||
.subtitle { color: var(--muted); font-weight: 700; }
|
||||
.header-actions {
|
||||
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;
|
||||
min-height: 38px;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.mureg-kpis {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(158px, 205px));
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.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;
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: transform 0.2s;
|
||||
&:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; }
|
||||
.lbl { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; color: var(--muted); }
|
||||
.val { font-size: 1.25rem; font-weight: 950; color: var(--text); }
|
||||
.lbl { font-size: 0.64rem; font-weight: 900; text-transform: uppercase; color: var(--muted); }
|
||||
.val { font-size: 1.02rem; font-weight: 950; color: var(--text); }
|
||||
.text-brand { color: var(--brand) !important; }
|
||||
}
|
||||
|
||||
.kpi.kpi-stack {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.search-group {
|
||||
border-radius: 12px; background: #fff; border: 1px solid rgba(17,18,20,0.15); display: flex; align-items: center;
|
||||
&:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); }
|
||||
.form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; &:focus { outline: none; } }
|
||||
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);
|
||||
|
||||
&: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;
|
||||
padding-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; }
|
||||
&:focus { outline: none; }
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: #dc3545; }
|
||||
}
|
||||
}
|
||||
|
||||
.select-glass {
|
||||
|
|
@ -118,6 +186,75 @@
|
|||
color: var(--blue); font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-brand,
|
||||
.btn-glass,
|
||||
.btn-primary,
|
||||
.btn-danger {
|
||||
border-radius: 12px;
|
||||
font-weight: 900;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s, filter 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
background-color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(227, 61, 207, 0.22);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 24px rgba(227, 61, 207, 0.28);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-glass {
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid rgba(3, 15, 170, 0.24);
|
||||
color: var(--blue);
|
||||
box-shadow: 0 6px 16px rgba(3, 15, 170, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
box-shadow: 0 8px 18px rgba(227, 61, 207, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #1543ff, #030faa);
|
||||
border-color: #030faa;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(3, 15, 170, 0.28);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 24px rgba(3, 15, 170, 0.3);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 10px 22px rgba(220, 38, 38, 0.26);
|
||||
}
|
||||
|
||||
/* BODY E GRUPOS */
|
||||
.geral-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.groups-container { padding: 16px; overflow-y: auto; height: 100%; }
|
||||
|
|
@ -166,10 +303,23 @@
|
|||
.text-blue { color: var(--blue) !important; }
|
||||
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.actions-col { min-width: 152px; }
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; color: rgba(17,18,20,0.5);
|
||||
width: 32px; height: 32px; border: none; background: rgba(17,18,20,0.04); border-radius: 8px; color: rgba(17,18,20,0.6);
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
|
||||
&:hover { background: rgba(3,15,170,0.1); color: var(--blue); }
|
||||
&:hover { background: rgba(17,18,20,0.08); color: var(--text); transform: translateY(-1px); }
|
||||
&.primary:hover { background: rgba(3,15,170,0.1); color: var(--blue); }
|
||||
&.danger:hover { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
|
|
@ -177,10 +327,309 @@
|
|||
padding: 14px 24px; border-top: 1px solid rgba(17, 18, 20, 0.06); display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.pagination-modern .page-link { color: var(--blue); font-weight: 900; border-radius: 10px; border: 1px solid rgba(17,18,20,0.1); background: #fff; margin: 0 2px; }
|
||||
.pagination-modern .page-link:hover { border-color: var(--brand); color: var(--brand); background: rgba(255, 255, 255, 0.98); }
|
||||
.pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
|
||||
/* MODAL */
|
||||
.lg-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); }
|
||||
.lg-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%);
|
||||
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-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); width: 600px; overflow: hidden; animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.lg-modal-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(255,255,255,0.86);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 60px -18px rgba(0, 0, 0, 0.4);
|
||||
width: min(860px, 96vw);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.lg-modal-card.modal-lg { width: min(760px, 94vw); }
|
||||
.lg-modal-card.modal-xl { width: min(1040px, 95vw); max-height: 86vh; }
|
||||
|
||||
.lg-modal-card .modal-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
background: linear-gradient(180deg, rgba(227, 61, 207, 0.09), rgba(255, 255, 255, 0.95) 70%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lg-modal-card .modal-title {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lg-modal-card .icon-bg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
background: rgba(3, 15, 170, 0.1);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.lg-modal-card .icon-bg.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); }
|
||||
.lg-modal-card .icon-bg.danger-soft { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
|
||||
|
||||
.lg-modal-card .modal-body { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.lg-modal-card .modal-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(180deg, #ffffff, rgba(248, 249, 251, 0.96));
|
||||
}
|
||||
.lg-modal-card.create-modal { width: min(1080px, 95vw); max-height: 86vh; }
|
||||
.lg-modal-card.create-modal .modal-header { background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), #ffffff 70%); }
|
||||
.lg-modal-card.create-modal .modal-body { background: linear-gradient(180deg, rgba(248, 249, 250, 0.96), rgba(255, 255, 255, 0.98)); }
|
||||
.lg-modal-card.create-modal .edit-sections { gap: 14px; }
|
||||
.lg-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); }
|
||||
.lg-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)); }
|
||||
.lg-modal-card.create-modal .box-body { background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 250, 252, 0.96)); }
|
||||
.lg-modal-card.create-modal .form-field label { color: rgba(17, 18, 20, 0.68); }
|
||||
.lg-modal-card.create-modal .form-control,
|
||||
.lg-modal-card.create-modal .form-select { min-height: 40px; }
|
||||
.lg-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));
|
||||
}
|
||||
.lg-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;
|
||||
}
|
||||
.lg-modal-card.create-modal .modal-footer .btn.me-2 { margin-right: 0 !important; }
|
||||
.bg-light-gray { background-color: #f8f9fa; }
|
||||
|
||||
.lg-modal-card .btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 18, 20, 0.04);
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(17, 18, 20, 0.08);
|
||||
color: var(--brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popUp { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||||
|
||||
.details-dashboard { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||
.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.06); box-shadow: 0 2px 10px rgba(0,0,0,0.03); overflow: hidden; }
|
||||
|
||||
.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(0,0,0,0.05);
|
||||
background: #fdfdfd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.box-header.justify-content-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--brand);
|
||||
background: linear-gradient(135deg, rgba(227, 61, 207, 0.08), rgba(59, 130, 246, 0.08));
|
||||
}
|
||||
|
||||
.box-body { padding: 16px; }
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: rgba(245, 245, 247, 0.55);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
|
||||
&.span-2 { grid-column: span 2; }
|
||||
|
||||
.lbl {
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 800;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 84px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(25, 135, 84, 0.12);
|
||||
color: #157347;
|
||||
border: 1px solid rgba(25, 135, 84, 0.24);
|
||||
}
|
||||
|
||||
.status-pill.is-danger {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: #b02a37;
|
||||
border-color: rgba(220, 53, 69, 0.24);
|
||||
}
|
||||
|
||||
.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;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
|
||||
i:not(.transition-icon) { color: var(--brand); margin-right: 6px; }
|
||||
&::-webkit-details-marker { display: none; }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.mureg-kpis {
|
||||
grid-template-columns: repeat(2, minmax(150px, 198px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.mureg-kpis {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-content: stretch;
|
||||
}
|
||||
.lg-modal-card { border-radius: 16px; }
|
||||
.lg-modal-card .modal-header { padding: 12px 16px; }
|
||||
.lg-modal-card .modal-body { padding: 16px !important; }
|
||||
.lg-modal-card.create-modal .modal-footer { flex-direction: column-reverse; }
|
||||
.lg-modal-card.create-modal .modal-footer .btn { width: 100%; min-width: 0; }
|
||||
.form-grid,
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.info-item.span-2,
|
||||
.form-field.span-2 { grid-column: span 1; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,25 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult } from '../../services/vigencia.service';
|
||||
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
|
||||
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { LinesService, MobileLineDetail } from '../../services/lines.service';
|
||||
import { PlanAutoFillService } from '../../services/plan-autofill.service';
|
||||
|
||||
type SortDir = 'asc' | 'desc';
|
||||
type ToastType = 'success' | 'danger';
|
||||
type ViewMode = 'lines' | 'groups';
|
||||
|
||||
interface LineOptionDto {
|
||||
id: string;
|
||||
item: number;
|
||||
linha: string | null;
|
||||
usuario: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-vigencia',
|
||||
standalone: true,
|
||||
|
|
@ -16,7 +27,7 @@ type ViewMode = 'lines' | 'groups';
|
|||
templateUrl: './vigencia.html',
|
||||
styleUrls: ['./vigencia.scss'],
|
||||
})
|
||||
export class VigenciaComponent implements OnInit {
|
||||
export class VigenciaComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
errorMsg = '';
|
||||
|
||||
|
|
@ -46,7 +57,6 @@ export class VigenciaComponent implements OnInit {
|
|||
kpiTotalClientes = 0;
|
||||
kpiTotalLinhas = 0;
|
||||
kpiTotalVencidos = 0;
|
||||
kpiValorTotal = 0;
|
||||
|
||||
// === ACORDEÃO ===
|
||||
expandedGroup: string | null = null;
|
||||
|
|
@ -56,18 +66,63 @@ export class VigenciaComponent implements OnInit {
|
|||
// UI
|
||||
detailsOpen = false;
|
||||
selectedRow: VigenciaRow | null = null;
|
||||
editOpen = false;
|
||||
editSaving = false;
|
||||
editModel: VigenciaRow | null = null;
|
||||
editEfetivacao = '';
|
||||
editTermino = '';
|
||||
editingId: string | null = null;
|
||||
deleteOpen = false;
|
||||
deleteTarget: VigenciaRow | null = null;
|
||||
|
||||
createOpen = false;
|
||||
createSaving = false;
|
||||
createModel: any = {
|
||||
selectedClient: '',
|
||||
mobileLineId: '',
|
||||
item: '',
|
||||
conta: '',
|
||||
linha: '',
|
||||
cliente: '',
|
||||
usuario: '',
|
||||
planoContrato: '',
|
||||
total: null
|
||||
};
|
||||
createEfetivacao = '';
|
||||
createTermino = '';
|
||||
|
||||
lineOptionsCreate: LineOptionDto[] = [];
|
||||
createClientsLoading = false;
|
||||
createLinesLoading = false;
|
||||
clientsFromGeral: string[] = [];
|
||||
planOptions: string[] = [];
|
||||
|
||||
isAdmin = false;
|
||||
toastOpen = false;
|
||||
toastMessage = '';
|
||||
toastType: ToastType = 'success';
|
||||
private toastTimer: any = null;
|
||||
private searchTimer: any = null;
|
||||
|
||||
constructor(private vigenciaService: VigenciaService) {}
|
||||
constructor(
|
||||
private vigenciaService: VigenciaService,
|
||||
private authService: AuthService,
|
||||
private linesService: LinesService,
|
||||
private planAutoFill: PlanAutoFillService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isAdmin = this.authService.hasRole('admin');
|
||||
this.loadClients();
|
||||
this.loadPlanRules();
|
||||
this.fetch(1);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||
}
|
||||
|
||||
setView(mode: ViewMode): void {
|
||||
if (this.viewMode === mode) return;
|
||||
this.viewMode = mode;
|
||||
|
|
@ -85,6 +140,15 @@ export class VigenciaComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
private async loadPlanRules() {
|
||||
try {
|
||||
await this.planAutoFill.load();
|
||||
this.planOptions = this.planAutoFill.getPlanOptions();
|
||||
} catch {
|
||||
this.planOptions = [];
|
||||
}
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
|
||||
}
|
||||
|
|
@ -120,7 +184,6 @@ export class VigenciaComponent implements OnInit {
|
|||
this.kpiTotalClientes = res.kpis.totalClientes;
|
||||
this.kpiTotalLinhas = res.kpis.totalLinhas;
|
||||
this.kpiTotalVencidos = res.kpis.totalVencidos;
|
||||
this.kpiValorTotal = res.kpis.valorTotal;
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
|
|
@ -199,10 +262,298 @@ export class VigenciaComponent implements OnInit {
|
|||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
clearFilters() { this.search = ''; this.fetch(1); }
|
||||
onSearchChange() {
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => this.fetch(1), 300);
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.search = '';
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.fetch(1);
|
||||
}
|
||||
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
|
||||
closeDetails() { this.detailsOpen = false; }
|
||||
|
||||
openEdit(r: VigenciaRow) {
|
||||
if (!this.isAdmin) return;
|
||||
this.editingId = r.id;
|
||||
this.editModel = { ...r };
|
||||
this.editEfetivacao = this.toDateInput(r.dtEfetivacaoServico);
|
||||
this.editTermino = this.toDateInput(r.dtTerminoFidelizacao);
|
||||
this.editOpen = true;
|
||||
}
|
||||
|
||||
closeEdit() {
|
||||
this.editOpen = false;
|
||||
this.editSaving = false;
|
||||
this.editModel = null;
|
||||
this.editEfetivacao = '';
|
||||
this.editTermino = '';
|
||||
this.editingId = null;
|
||||
}
|
||||
|
||||
saveEdit() {
|
||||
if (!this.editModel || !this.editingId) return;
|
||||
this.editSaving = true;
|
||||
|
||||
const payload: UpdateVigenciaRequest = {
|
||||
item: this.toNullableNumber(this.editModel.item),
|
||||
conta: this.editModel.conta,
|
||||
linha: this.editModel.linha,
|
||||
cliente: this.editModel.cliente,
|
||||
usuario: this.editModel.usuario,
|
||||
planoContrato: this.editModel.planoContrato,
|
||||
dtEfetivacaoServico: this.dateInputToIso(this.editEfetivacao),
|
||||
dtTerminoFidelizacao: this.dateInputToIso(this.editTermino),
|
||||
total: this.toNullableNumber(this.editModel.total)
|
||||
};
|
||||
|
||||
this.vigenciaService.update(this.editingId, payload).subscribe({
|
||||
next: () => {
|
||||
this.editSaving = false;
|
||||
this.closeEdit();
|
||||
this.fetch();
|
||||
this.showToast('Registro atualizado!', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.editSaving = false;
|
||||
this.showToast('Erro ao salvar.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// CREATE
|
||||
// ==========================
|
||||
openCreate() {
|
||||
if (!this.isAdmin) return;
|
||||
this.resetCreateModel();
|
||||
this.createOpen = true;
|
||||
this.preloadGeralClients();
|
||||
}
|
||||
|
||||
closeCreate() {
|
||||
this.createOpen = false;
|
||||
this.createSaving = false;
|
||||
this.createModel = null;
|
||||
}
|
||||
|
||||
private resetCreateModel() {
|
||||
this.createModel = {
|
||||
selectedClient: '',
|
||||
mobileLineId: '',
|
||||
item: '',
|
||||
conta: '',
|
||||
linha: '',
|
||||
cliente: '',
|
||||
usuario: '',
|
||||
planoContrato: '',
|
||||
total: null
|
||||
};
|
||||
this.createEfetivacao = '';
|
||||
this.createTermino = '';
|
||||
this.lineOptionsCreate = [];
|
||||
this.createLinesLoading = false;
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
|
||||
private preloadGeralClients() {
|
||||
this.createClientsLoading = true;
|
||||
this.linesService.getClients().subscribe({
|
||||
next: (list) => {
|
||||
this.clientsFromGeral = list ?? [];
|
||||
this.createClientsLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.clientsFromGeral = [];
|
||||
this.createClientsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCreateClientChange() {
|
||||
const c = (this.createModel.selectedClient ?? '').trim();
|
||||
this.createModel.mobileLineId = '';
|
||||
this.createModel.linha = '';
|
||||
this.createModel.conta = '';
|
||||
this.createModel.usuario = '';
|
||||
this.createModel.planoContrato = '';
|
||||
this.createModel.total = null;
|
||||
this.createModel.cliente = c;
|
||||
this.lineOptionsCreate = [];
|
||||
|
||||
if (c) this.loadLinesForClient(c);
|
||||
}
|
||||
|
||||
private loadLinesForClient(cliente: string) {
|
||||
const c = (cliente ?? '').trim();
|
||||
if (!c) return;
|
||||
|
||||
this.createLinesLoading = true;
|
||||
this.linesService.getLinesByClient(c).subscribe({
|
||||
next: (items: any[]) => {
|
||||
const mapped: LineOptionDto[] = (items ?? [])
|
||||
.filter(x => !!String(x?.id ?? '').trim())
|
||||
.map(x => ({
|
||||
id: String(x.id),
|
||||
item: Number(x.item ?? 0),
|
||||
linha: x.linha ?? null,
|
||||
usuario: x.usuario ?? null,
|
||||
label: `${x.item ?? ''} • ${x.linha ?? '-'} • ${x.usuario ?? 'SEM USUÁRIO'}`
|
||||
}))
|
||||
.filter(x => !!String(x.linha ?? '').trim());
|
||||
|
||||
this.lineOptionsCreate = mapped;
|
||||
this.createLinesLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.lineOptionsCreate = [];
|
||||
this.createLinesLoading = false;
|
||||
this.showToast('Erro ao carregar linhas da GERAL.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCreateLineChange() {
|
||||
const id = String(this.createModel.mobileLineId ?? '').trim();
|
||||
if (!id) return;
|
||||
|
||||
this.linesService.getById(id).subscribe({
|
||||
next: (d: MobileLineDetail) => this.applyLineDetailToCreate(d),
|
||||
error: () => this.showToast('Erro ao carregar dados da linha.', 'danger')
|
||||
});
|
||||
}
|
||||
|
||||
private applyLineDetailToCreate(d: MobileLineDetail) {
|
||||
this.createModel.linha = d.linha ?? '';
|
||||
this.createModel.conta = d.conta ?? '';
|
||||
this.createModel.cliente = d.cliente ?? this.createModel.cliente ?? '';
|
||||
this.createModel.usuario = d.usuario ?? '';
|
||||
this.createModel.planoContrato = d.planoContrato ?? '';
|
||||
this.createEfetivacao = this.toDateInput(d.dtEfetivacaoServico ?? null);
|
||||
this.createTermino = this.toDateInput(d.dtTerminoFidelizacao ?? null);
|
||||
|
||||
this.ensurePlanOption(this.createModel.planoContrato);
|
||||
|
||||
if (!String(this.createModel.item ?? '').trim() && d.item) {
|
||||
this.createModel.item = String(d.item);
|
||||
}
|
||||
|
||||
this.onCreatePlanChange();
|
||||
}
|
||||
|
||||
onCreatePlanChange() {
|
||||
this.ensurePlanOption(this.createModel?.planoContrato);
|
||||
this.applyPlanSuggestion(this.createModel);
|
||||
}
|
||||
|
||||
onEditPlanChange() {
|
||||
if (!this.editModel) return;
|
||||
this.ensurePlanOption(this.editModel?.planoContrato);
|
||||
this.applyPlanSuggestion(this.editModel);
|
||||
}
|
||||
|
||||
private applyPlanSuggestion(model: any) {
|
||||
const plan = (model?.planoContrato ?? '').toString().trim();
|
||||
if (!plan) return;
|
||||
|
||||
const suggestion = this.planAutoFill.suggest(plan);
|
||||
if (!suggestion) return;
|
||||
|
||||
if (suggestion.valorPlano != null) {
|
||||
model.total = suggestion.valorPlano;
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePlanOption(plan: any) {
|
||||
const p = (plan ?? '').toString().trim();
|
||||
if (!p) return;
|
||||
if (!this.planOptions.includes(p)) {
|
||||
this.planOptions = [p, ...this.planOptions];
|
||||
}
|
||||
}
|
||||
|
||||
saveCreate() {
|
||||
if (!this.createModel) return;
|
||||
this.applyPlanSuggestion(this.createModel);
|
||||
|
||||
const payload = {
|
||||
item: this.toNullableNumber(this.createModel.item),
|
||||
conta: this.createModel.conta,
|
||||
linha: this.createModel.linha,
|
||||
cliente: this.createModel.cliente,
|
||||
usuario: this.createModel.usuario,
|
||||
planoContrato: this.createModel.planoContrato,
|
||||
dtEfetivacaoServico: this.dateInputToIso(this.createEfetivacao),
|
||||
dtTerminoFidelizacao: this.dateInputToIso(this.createTermino),
|
||||
total: this.toNullableNumber(this.createModel.total)
|
||||
};
|
||||
|
||||
this.createSaving = true;
|
||||
this.vigenciaService.create(payload).subscribe({
|
||||
next: () => {
|
||||
this.createSaving = false;
|
||||
this.closeCreate();
|
||||
this.fetch();
|
||||
this.showToast('Vigência criada com sucesso!', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.createSaving = false;
|
||||
this.showToast('Erro ao criar vigência.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openDelete(r: VigenciaRow) {
|
||||
if (!this.isAdmin) return;
|
||||
this.deleteTarget = r;
|
||||
this.deleteOpen = true;
|
||||
}
|
||||
|
||||
cancelDelete() {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
}
|
||||
|
||||
confirmDelete() {
|
||||
if (!this.deleteTarget) return;
|
||||
const id = this.deleteTarget.id;
|
||||
this.vigenciaService.remove(id).subscribe({
|
||||
next: () => {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.fetch();
|
||||
this.showToast('Registro removido.', 'success');
|
||||
},
|
||||
error: () => {
|
||||
this.deleteOpen = false;
|
||||
this.deleteTarget = null;
|
||||
this.showToast('Erro ao remover.', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toDateInput(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private dateInputToIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
private toNullableNumber(value: any): number | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
handleError(err: HttpErrorResponse, msg: string) {
|
||||
this.loading = false;
|
||||
this.expandedLoading = false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
export interface RegisterPayload {
|
||||
|
|
@ -16,35 +17,112 @@ export interface LoginPayload {
|
|||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginOptions {
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthUserProfile {
|
||||
id: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
tenantId: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private baseUrl = `${environment.apiUrl}/auth`;
|
||||
private userProfileSubject = new BehaviorSubject<AuthUserProfile | null>(null);
|
||||
readonly userProfile$ = this.userProfileSubject.asObservable();
|
||||
private readonly tokenStorageKey = 'token';
|
||||
private readonly tokenExpiresAtKey = 'tokenExpiresAt';
|
||||
private readonly rememberMeHours = 6;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
constructor(private http: HttpClient) {
|
||||
this.syncUserProfileFromToken();
|
||||
}
|
||||
|
||||
register(payload: RegisterPayload) {
|
||||
return this.http.post<{ token: string }>(`${this.baseUrl}/register`, payload)
|
||||
.pipe(tap(r => localStorage.setItem('token', r.token)));
|
||||
.pipe(tap(r => this.setToken(r.token)));
|
||||
}
|
||||
|
||||
login(payload: LoginPayload) {
|
||||
return this.http.post<{ token: string }>(`${this.baseUrl}/login`, payload)
|
||||
.pipe(tap(r => localStorage.setItem('token', r.token)));
|
||||
login(payload: LoginPayload, options?: LoginOptions) {
|
||||
return this.http.post<LoginResponse>(`${this.baseUrl}/login`, payload)
|
||||
.pipe(
|
||||
tap((r) => {
|
||||
const token = this.resolveLoginToken(r);
|
||||
if (!token) return;
|
||||
this.setToken(token, options?.rememberMe ?? false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('token');
|
||||
if (typeof window === 'undefined') {
|
||||
this.userProfileSubject.next(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTokenStorage(localStorage);
|
||||
this.clearTokenStorage(sessionStorage);
|
||||
this.userProfileSubject.next(null);
|
||||
}
|
||||
|
||||
setToken(token: string, rememberMe = false) {
|
||||
if (typeof window === 'undefined') return;
|
||||
this.clearTokenStorage(localStorage);
|
||||
this.clearTokenStorage(sessionStorage);
|
||||
|
||||
if (rememberMe) {
|
||||
const expiresAt = Date.now() + this.rememberMeHours * 60 * 60 * 1000;
|
||||
localStorage.setItem(this.tokenStorageKey, token);
|
||||
localStorage.setItem(this.tokenExpiresAtKey, String(expiresAt));
|
||||
} else {
|
||||
sessionStorage.setItem(this.tokenStorageKey, token);
|
||||
}
|
||||
|
||||
this.syncUserProfileFromToken();
|
||||
}
|
||||
|
||||
get token(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('token');
|
||||
this.cleanupExpiredRememberSession();
|
||||
|
||||
const sessionToken = sessionStorage.getItem(this.tokenStorageKey);
|
||||
if (sessionToken) return sessionToken;
|
||||
|
||||
return localStorage.getItem(this.tokenStorageKey);
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
get currentUserProfile(): AuthUserProfile | null {
|
||||
return this.userProfileSubject.value;
|
||||
}
|
||||
|
||||
syncUserProfileFromToken() {
|
||||
this.userProfileSubject.next(this.buildProfileFromToken());
|
||||
}
|
||||
|
||||
updateUserProfile(profile: Pick<AuthUserProfile, 'nome' | 'email'>) {
|
||||
const current = this.userProfileSubject.value;
|
||||
if (!current) return;
|
||||
|
||||
this.userProfileSubject.next({
|
||||
...current,
|
||||
nome: profile.nome.trim(),
|
||||
email: profile.email.trim().toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
getTokenPayload(): Record<string, any> | null {
|
||||
const token = this.token;
|
||||
if (!token) return null;
|
||||
|
|
@ -66,6 +144,10 @@ export class AuthService {
|
|||
getRoles(): string[] {
|
||||
const payload = this.getTokenPayload();
|
||||
if (!payload) return [];
|
||||
return this.extractRoles(payload);
|
||||
}
|
||||
|
||||
private extractRoles(payload: Record<string, any>): string[] {
|
||||
const possibleKeys = [
|
||||
'role',
|
||||
'roles',
|
||||
|
|
@ -81,9 +163,74 @@ export class AuthService {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,22 @@ export interface BillingItem {
|
|||
|
||||
aparelho?: string | null;
|
||||
formaPagamento?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface BillingUpdateRequest {
|
||||
tipo?: string;
|
||||
item?: number | null;
|
||||
cliente?: string | null;
|
||||
qtdLinhas?: number | null;
|
||||
franquiaVivo?: number | null;
|
||||
valorContratoVivo?: number | null;
|
||||
franquiaLine?: number | null;
|
||||
valorContratoLine?: number | null;
|
||||
lucro?: number | null;
|
||||
aparelho?: string | null;
|
||||
formaPagamento?: string | null;
|
||||
}
|
||||
|
||||
export interface BillingQuery {
|
||||
|
|
@ -84,4 +100,16 @@ export class BillingService {
|
|||
|
||||
return this.getPaged(q).pipe(map((res) => res.items ?? []));
|
||||
}
|
||||
|
||||
getById(id: string): Observable<BillingItem> {
|
||||
return this.http.get<BillingItem>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
update(id: string, payload: BillingUpdateRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,18 @@ export interface ChipVirgemListDto {
|
|||
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;
|
||||
|
|
@ -34,8 +44,28 @@ export interface ControleRecebidoListDto {
|
|||
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;
|
||||
|
|
@ -67,6 +97,18 @@ export class ChipsControleService {
|
|||
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;
|
||||
|
|
@ -95,4 +137,16 @@ export class ChipsControleService {
|
|||
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;
|
||||
linha: string | null;
|
||||
cliente: string | null;
|
||||
tipoPessoa?: string | null;
|
||||
nome?: string | null;
|
||||
razaoSocial?: string | null;
|
||||
cnpj?: string | null;
|
||||
cpf: string | null;
|
||||
email: string | null;
|
||||
celular: string | null;
|
||||
|
|
@ -26,10 +30,30 @@ export interface UserDataRow {
|
|||
dataNascimento: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserDataRequest {
|
||||
item?: number | null;
|
||||
linha?: string | null;
|
||||
cliente?: string | null;
|
||||
tipoPessoa?: string | null;
|
||||
nome?: string | null;
|
||||
razaoSocial?: string | null;
|
||||
cnpj?: string | null;
|
||||
cpf?: string | null;
|
||||
rg?: string | null;
|
||||
dataNascimento?: string | null;
|
||||
email?: string | null;
|
||||
endereco?: string | null;
|
||||
celular?: string | null;
|
||||
telefoneFixo?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserDataRequest extends UpdateUserDataRequest {}
|
||||
|
||||
export interface UserDataClientGroup {
|
||||
cliente: string;
|
||||
totalRegistros: number;
|
||||
comCpf: number;
|
||||
comCnpj: number;
|
||||
comEmail: number;
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +61,7 @@ export interface UserDataKpis {
|
|||
totalRegistros: number;
|
||||
clientesUnicos: number;
|
||||
comCpf: number;
|
||||
comCnpj: number;
|
||||
comEmail: number;
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +81,7 @@ export class DadosUsuariosService {
|
|||
|
||||
getGroups(opts: {
|
||||
search?: string;
|
||||
tipo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
|
|
@ -63,6 +89,7 @@ export class DadosUsuariosService {
|
|||
}): Observable<UserDataGroupResponse> {
|
||||
let params = new HttpParams();
|
||||
if (opts.search) params = params.set('search', opts.search);
|
||||
if (opts.tipo) params = params.set('tipo', opts.tipo);
|
||||
|
||||
params = params.set('page', String(opts.page || 1));
|
||||
params = params.set('pageSize', String(opts.pageSize || 10));
|
||||
|
|
@ -75,6 +102,7 @@ export class DadosUsuariosService {
|
|||
getRows(opts: {
|
||||
search?: string;
|
||||
client?: string;
|
||||
tipo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
|
|
@ -83,6 +111,7 @@ export class DadosUsuariosService {
|
|||
let params = new HttpParams();
|
||||
if (opts.search) params = params.set('search', opts.search);
|
||||
if (opts.client) params = params.set('client', opts.client);
|
||||
if (opts.tipo) params = params.set('tipo', opts.tipo);
|
||||
|
||||
params = params.set('page', String(opts.page || 1));
|
||||
params = params.set('pageSize', String(opts.pageSize || 20));
|
||||
|
|
@ -92,11 +121,25 @@ export class DadosUsuariosService {
|
|||
return this.http.get<PagedResult<UserDataRow>>(`${this.baseApi}/user-data`, { params });
|
||||
}
|
||||
|
||||
getClients(): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`);
|
||||
getClients(tipo?: string): Observable<string[]> {
|
||||
let params = new HttpParams();
|
||||
if (tipo) params = params.set('tipo', tipo);
|
||||
return this.http.get<string[]>(`${this.baseApi}/user-data/clients`, { params });
|
||||
}
|
||||
|
||||
getById(id: string): Observable<UserDataRow> {
|
||||
return this.http.get<UserDataRow>(`${this.baseApi}/user-data/${id}`);
|
||||
}
|
||||
|
||||
update(id: string, payload: UpdateUserDataRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseApi}/user-data/${id}`, payload);
|
||||
}
|
||||
|
||||
create(payload: CreateUserDataRequest): Observable<UserDataRow> {
|
||||
return this.http.post<UserDataRow>(`${this.baseApi}/user-data`, payload);
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseApi}/user-data/${id}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ export interface MobileLineDetail extends MobileLineList {
|
|||
solicitante?: string | null;
|
||||
dataEntregaOpera?: string | null;
|
||||
dataEntregaCliente?: string | null;
|
||||
dtEfetivacaoServico?: string | null;
|
||||
dtTerminoFidelizacao?: string | null;
|
||||
}
|
||||
|
||||
export interface LineOption {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
|
@ -20,6 +20,10 @@ export type NotificationDto = {
|
|||
cliente?: string | null;
|
||||
linha?: string | null;
|
||||
usuario?: string | null;
|
||||
conta?: string | null;
|
||||
planoContrato?: string | null;
|
||||
dtEfetivacaoServico?: string | null;
|
||||
dtTerminoFidelizacao?: string | null;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
|
@ -38,4 +42,29 @@ export class NotificationsService {
|
|||
markAsRead(id: string): Observable<void> {
|
||||
return this.http.patch<void>(`${this.baseApi}/notifications/${id}/read`, {});
|
||||
}
|
||||
|
||||
markAllAsRead(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/read-all`, body, { params });
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface PagedResult<T> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface ParcelamentoListItem {
|
||||
id: string;
|
||||
anoRef?: number | null;
|
||||
item?: number | null;
|
||||
linha?: string | null;
|
||||
cliente?: string | null;
|
||||
qtParcelas?: string | null;
|
||||
parcelaAtual?: number | null;
|
||||
totalParcelas?: number | null;
|
||||
valorCheio?: number | string | null;
|
||||
desconto?: number | string | null;
|
||||
valorComDesconto?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ParcelamentoParcela {
|
||||
competencia: string;
|
||||
valor?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ParcelamentoAnnualMonth {
|
||||
month: number;
|
||||
valor?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ParcelamentoAnnualRow {
|
||||
year: number;
|
||||
total?: number | string | null;
|
||||
months?: ParcelamentoAnnualMonth[];
|
||||
}
|
||||
|
||||
export interface ParcelamentoMonthInput {
|
||||
competencia: string;
|
||||
valor?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ParcelamentoUpsertRequest {
|
||||
anoRef?: number | null;
|
||||
item?: number | null;
|
||||
linha?: string | null;
|
||||
cliente?: string | null;
|
||||
qtParcelas?: string | null;
|
||||
parcelaAtual?: number | null;
|
||||
totalParcelas?: number | null;
|
||||
valorCheio?: number | string | null;
|
||||
desconto?: number | string | null;
|
||||
valorComDesconto?: number | string | null;
|
||||
monthValues?: ParcelamentoMonthInput[] | null;
|
||||
}
|
||||
|
||||
export interface ParcelamentoDetail extends ParcelamentoListItem {
|
||||
parcelasMensais?: ParcelamentoParcela[];
|
||||
annualRows?: ParcelamentoAnnualRow[];
|
||||
}
|
||||
|
||||
export interface ParcelamentoDetailResponse extends ParcelamentoListItem {
|
||||
parcelasMensais?: ParcelamentoParcela[];
|
||||
parcelas?: ParcelamentoParcela[];
|
||||
monthValues?: ParcelamentoParcela[];
|
||||
annualRows?: ParcelamentoAnnualRow[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParcelamentosService {
|
||||
private readonly baseApi: string;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
}
|
||||
|
||||
list(filters: {
|
||||
anoRef?: number;
|
||||
linha?: string;
|
||||
cliente?: string;
|
||||
competenciaAno?: number;
|
||||
competenciaMes?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Observable<PagedResult<ParcelamentoListItem>> {
|
||||
let params = new HttpParams();
|
||||
if (filters.anoRef !== undefined) params = params.set('anoRef', String(filters.anoRef));
|
||||
if (filters.linha && filters.linha.trim()) params = params.set('linha', filters.linha.trim());
|
||||
if (filters.cliente && filters.cliente.trim()) params = params.set('cliente', filters.cliente.trim());
|
||||
if (filters.competenciaAno !== undefined) params = params.set('competenciaAno', String(filters.competenciaAno));
|
||||
if (filters.competenciaMes !== undefined) params = params.set('competenciaMes', String(filters.competenciaMes));
|
||||
params = params.set('page', String(filters.page ?? 1));
|
||||
params = params.set('pageSize', String(filters.pageSize ?? 10));
|
||||
|
||||
return this.http.get<PagedResult<ParcelamentoListItem>>(`${this.baseApi}/parcelamentos`, { params });
|
||||
}
|
||||
|
||||
getById(id: string): Observable<ParcelamentoDetailResponse> {
|
||||
return this.http.get<ParcelamentoDetailResponse>(`${this.baseApi}/parcelamentos/${id}`);
|
||||
}
|
||||
|
||||
create(payload: ParcelamentoUpsertRequest): Observable<ParcelamentoDetailResponse> {
|
||||
return this.http.post<ParcelamentoDetailResponse>(`${this.baseApi}/parcelamentos`, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: ParcelamentoUpsertRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseApi}/parcelamentos/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseApi}/parcelamentos/${id}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ResumoService, PlanoContratoResumo, MacrophonyPlan } from './resumo.service';
|
||||
|
||||
export type PlanSuggestion = {
|
||||
franquiaGb?: number | null;
|
||||
valorPlano?: number | null;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanAutoFillService {
|
||||
private loaded = false;
|
||||
private loadingPromise: Promise<void> | null = null;
|
||||
private planMap = new Map<string, PlanSuggestion>();
|
||||
private planOptions: string[] = [];
|
||||
|
||||
constructor(private resumoService: ResumoService) {}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
if (this.loadingPromise) return this.loadingPromise;
|
||||
|
||||
this.loadingPromise = firstValueFrom(this.resumoService.getResumo())
|
||||
.then((res) => {
|
||||
const items: Array<PlanoContratoResumo | MacrophonyPlan> = [
|
||||
...(res?.planoContratoResumos ?? []),
|
||||
...(res?.macrophonyPlans ?? [])
|
||||
];
|
||||
|
||||
items.forEach((row) => this.addPlanRule(row));
|
||||
this.planOptions = Array.from(new Set(this.planOptions)).sort((a, b) =>
|
||||
a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
this.loaded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loaded = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingPromise = null;
|
||||
});
|
||||
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
getPlanOptions(): string[] {
|
||||
return [...this.planOptions];
|
||||
}
|
||||
|
||||
suggest(planName: string | null | undefined): PlanSuggestion | null {
|
||||
const plan = (planName ?? '').trim();
|
||||
if (!plan) return null;
|
||||
|
||||
const key = this.normalizePlan(plan);
|
||||
const fromMap = this.planMap.get(key);
|
||||
|
||||
const franquia = fromMap?.franquiaGb ?? this.parseFranquiaFromPlan(plan);
|
||||
const valorPlano = fromMap?.valorPlano ?? null;
|
||||
|
||||
if (franquia == null && valorPlano == null) return null;
|
||||
return { franquiaGb: franquia ?? null, valorPlano };
|
||||
}
|
||||
|
||||
private addPlanRule(row: PlanoContratoResumo | MacrophonyPlan) {
|
||||
const plano = (row?.planoContrato ?? '').toString().trim();
|
||||
if (!plano) return;
|
||||
|
||||
const key = this.normalizePlan(plano);
|
||||
const current = this.planMap.get(key) || {};
|
||||
|
||||
const franquia = this.toNumber((row as any).franquiaGb ?? (row as any).gb);
|
||||
const valorPlano = this.toNumber((row as any).valorIndividualComSvas);
|
||||
|
||||
const next: PlanSuggestion = {
|
||||
franquiaGb: current.franquiaGb ?? franquia ?? null,
|
||||
valorPlano: current.valorPlano ?? valorPlano ?? null
|
||||
};
|
||||
|
||||
this.planMap.set(key, next);
|
||||
this.planOptions.push(plano);
|
||||
}
|
||||
|
||||
private normalizePlan(plan: string): string {
|
||||
return plan.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const cleaned = raw.replace(/[^0-9,.-]/g, '').replace(',', '.');
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
private parseFranquiaFromPlan(plan: string): number | null {
|
||||
const match = plan.match(/(\d+(?:[.,]\d+)?)\s*(GB|MB)/i);
|
||||
if (!match) return null;
|
||||
|
||||
const raw = match[1].replace(',', '.');
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
const value = parseFloat(raw);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
|
||||
if (unit === 'MB') {
|
||||
return Number((value / 1000).toFixed(4));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export type ProfileMeDto = {
|
||||
id: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type UpdateProfilePayload = {
|
||||
nome: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ChangePasswordPayload = {
|
||||
credencialAtual: string;
|
||||
novaCredencial: string;
|
||||
confirmarNovaCredencial: string;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileService {
|
||||
private readonly baseApi: string;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
|
||||
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
}
|
||||
|
||||
getMe(): Observable<ProfileMeDto> {
|
||||
return this.http.get<ProfileMeDto>(`${this.baseApi}/profile/me`);
|
||||
}
|
||||
|
||||
updateProfile(payload: UpdateProfilePayload): Observable<ProfileMeDto> {
|
||||
return this.http.patch<ProfileMeDto>(`${this.baseApi}/profile`, payload);
|
||||
}
|
||||
|
||||
changePassword(payload: ChangePasswordPayload): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseApi}/profile/change-password`, payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface MacrophonyPlan {
|
||||
planoContrato?: string | null;
|
||||
gb?: string | number | null;
|
||||
valorIndividualComSvas?: string | number | null;
|
||||
franquiaGb?: string | number | null;
|
||||
totalLinhas?: number | string | null;
|
||||
valorTotal?: string | number | null;
|
||||
vivoTravel?: boolean | string | number | null;
|
||||
}
|
||||
|
||||
export interface MacrophonyTotals {
|
||||
franquiaGbTotal?: string | number | null;
|
||||
totalLinhasTotal?: number | string | null;
|
||||
valorTotal?: string | number | null;
|
||||
}
|
||||
|
||||
export interface VivoLineResumo {
|
||||
skil?: string | null;
|
||||
cliente?: string | null;
|
||||
qtdLinhas?: number | string | null;
|
||||
franquiaTotal?: string | number | null;
|
||||
valorContratoVivo?: string | number | null;
|
||||
franquiaLine?: string | number | null;
|
||||
valorContratoLine?: string | number | null;
|
||||
lucro?: string | number | null;
|
||||
}
|
||||
|
||||
export interface VivoLineTotals {
|
||||
qtdLinhasTotal?: number | string | null;
|
||||
franquiaTotal?: string | number | null;
|
||||
valorContratoVivo?: string | number | null;
|
||||
franquiaLine?: string | number | null;
|
||||
valorContratoLine?: string | number | null;
|
||||
lucro?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ClienteEspecial {
|
||||
nome?: string | null;
|
||||
valor?: string | number | null;
|
||||
}
|
||||
|
||||
export interface PlanoContratoResumo {
|
||||
planoContrato?: string | null;
|
||||
gb?: string | number | null;
|
||||
valorIndividualComSvas?: string | number | null;
|
||||
franquiaGb?: string | number | null;
|
||||
totalLinhas?: number | string | null;
|
||||
valorTotal?: string | number | null;
|
||||
}
|
||||
|
||||
export interface PlanoContratoTotal {
|
||||
valorTotal?: string | number | null;
|
||||
}
|
||||
|
||||
export interface LineTotal {
|
||||
tipo?: string | null;
|
||||
valorTotalLine?: string | number | null;
|
||||
lucroTotalLine?: string | number | null;
|
||||
qtdLinhas?: number | string | null;
|
||||
}
|
||||
|
||||
export interface GbDistribuicao {
|
||||
gb?: string | number | null;
|
||||
qtd?: number | string | null;
|
||||
soma?: string | number | null;
|
||||
}
|
||||
|
||||
export interface GbDistribuicaoTotal {
|
||||
totalLinhas?: number | string | null;
|
||||
somaTotal?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ReservaLine {
|
||||
ddd?: string | number | null;
|
||||
franquiaGb?: string | number | null;
|
||||
qtdLinhas?: number | string | null;
|
||||
total?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ReservaPorFranquia {
|
||||
franquiaGb?: string | number | null;
|
||||
totalLinhas?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ReservaPorDdd {
|
||||
ddd?: string | number | null;
|
||||
totalLinhas?: number | string | null;
|
||||
porFranquia?: ReservaPorFranquia[];
|
||||
}
|
||||
|
||||
export interface ReservaTotal {
|
||||
qtdLinhasTotal?: number | string | null;
|
||||
total?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ResumoResponse {
|
||||
macrophonyPlans?: MacrophonyPlan[];
|
||||
macrophonyTotals?: MacrophonyTotals;
|
||||
vivoLineResumos?: VivoLineResumo[];
|
||||
vivoLineTotals?: VivoLineTotals;
|
||||
clienteEspeciais?: ClienteEspecial[];
|
||||
planoContratoResumos?: PlanoContratoResumo[];
|
||||
planoContratoTotal?: PlanoContratoTotal;
|
||||
lineTotais?: LineTotal[];
|
||||
gbDistribuicao?: GbDistribuicao[];
|
||||
gbDistribuicaoTotal?: GbDistribuicaoTotal;
|
||||
reservaLines?: ReservaLine[];
|
||||
reservaPorDdd?: ReservaPorDdd[];
|
||||
totalGeralLinhasReserva?: number | string | null;
|
||||
reservaTotal?: ReservaTotal;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ResumoService {
|
||||
private readonly apiBase: string;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
|
||||
this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||
}
|
||||
|
||||
getResumo() {
|
||||
return this.http.get<ResumoResponse>(`${this.apiBase}/resumo`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionNoticeService {
|
||||
private toastEl: HTMLElement | null = null;
|
||||
private toastBodyEl: HTMLElement | null = null;
|
||||
private toastHeaderEl: HTMLElement | null = null;
|
||||
private handling401 = false;
|
||||
private last401At = 0;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
@Inject(PLATFORM_ID) private platformId: object
|
||||
) {}
|
||||
|
||||
async handleUnauthorized(): Promise<void> {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (this.handling401 && now - this.last401At < 3000) return;
|
||||
this.handling401 = true;
|
||||
this.last401At = now;
|
||||
|
||||
await this.showToast('Sua sessão expirou. Faça login novamente.', 'danger');
|
||||
this.authService.logout();
|
||||
this.router.navigateByUrl('/login');
|
||||
|
||||
setTimeout(() => {
|
||||
this.handling401 = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async handleForbidden(): Promise<void> {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
await this.showToast('Acesso restrito.', 'warning');
|
||||
}
|
||||
|
||||
private ensureToast(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
if (this.toastEl && this.toastBodyEl && this.toastHeaderEl) return;
|
||||
|
||||
const doc = document;
|
||||
let container = doc.getElementById('lg-global-toast-container');
|
||||
if (!container) {
|
||||
container = doc.createElement('div');
|
||||
container.id = 'lg-global-toast-container';
|
||||
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
||||
container.style.zIndex = '10000';
|
||||
doc.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = doc.createElement('div');
|
||||
toast.className = 'toast text-bg-danger border-0 shadow';
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
|
||||
const header = doc.createElement('div');
|
||||
header.className = 'toast-header border-bottom-0';
|
||||
|
||||
const title = doc.createElement('strong');
|
||||
title.className = 'me-auto text-primary';
|
||||
title.textContent = 'LineGestão';
|
||||
|
||||
const closeBtn = doc.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'btn-close';
|
||||
closeBtn.setAttribute('data-bs-dismiss', 'toast');
|
||||
closeBtn.setAttribute('aria-label', 'Fechar');
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = doc.createElement('div');
|
||||
body.className = 'toast-body bg-white rounded-bottom text-dark';
|
||||
|
||||
toast.appendChild(header);
|
||||
toast.appendChild(body);
|
||||
container.appendChild(toast);
|
||||
|
||||
this.toastEl = toast;
|
||||
this.toastBodyEl = body;
|
||||
this.toastHeaderEl = header;
|
||||
}
|
||||
|
||||
private async showToast(message: string, variant: 'danger' | 'warning'): Promise<void> {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
this.ensureToast();
|
||||
if (!this.toastEl || !this.toastBodyEl || !this.toastHeaderEl) return;
|
||||
|
||||
this.toastBodyEl.textContent = message;
|
||||
this.toastEl.classList.remove('text-bg-danger', 'text-bg-warning');
|
||||
this.toastEl.classList.add(variant === 'warning' ? 'text-bg-warning' : 'text-bg-danger');
|
||||
|
||||
try {
|
||||
const bs = await import('bootstrap');
|
||||
const toastInstance = bs.Toast.getOrCreateInstance(this.toastEl, {
|
||||
autohide: true,
|
||||
delay: 3000
|
||||
});
|
||||
toastInstance.show();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,24 @@ export interface VigenciaRow {
|
|||
dtEfetivacaoServico: string | null;
|
||||
dtTerminoFidelizacao: string | null;
|
||||
total: number | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateVigenciaRequest {
|
||||
item?: number | null;
|
||||
conta?: string | null;
|
||||
linha?: string | null;
|
||||
cliente?: string | null;
|
||||
usuario?: string | null;
|
||||
planoContrato?: string | null;
|
||||
dtEfetivacaoServico?: string | null;
|
||||
dtTerminoFidelizacao?: string | null;
|
||||
total?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
|
||||
|
||||
export interface VigenciaClientGroup {
|
||||
cliente: string;
|
||||
linhas: number;
|
||||
|
|
@ -86,4 +102,20 @@ export class VigenciaService {
|
|||
getClients(): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${this.baseApi}/lines/vigencia/clients`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<VigenciaRow> {
|
||||
return this.http.get<VigenciaRow>(`${this.baseApi}/lines/vigencia/${id}`);
|
||||
}
|
||||
|
||||
update(id: string, payload: UpdateVigenciaRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.baseApi}/lines/vigencia/${id}`, payload);
|
||||
}
|
||||
|
||||
create(payload: CreateVigenciaRequest): Observable<VigenciaRow> {
|
||||
return this.http.post<VigenciaRow>(`${this.baseApi}/lines/vigencia`, payload);
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>LineGestaoFrontend</title>
|
||||
<title>LineGestão</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/png" href="logo.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localePt from '@angular/common/locales/pt';
|
||||
import { App } from './app/app';
|
||||
import { config } from './app/app.config.server';
|
||||
|
||||
registerLocaleData(localePt, 'pt-BR');
|
||||
|
||||
const bootstrap = (context: BootstrapContext) =>
|
||||
bootstrapApplication(App, config, context);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localePt from '@angular/common/locales/pt';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app';
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
|
||||
registerLocaleData(localePt, 'pt-BR');
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
|
|
|
|||
|
|
@ -83,7 +83,12 @@ select.form-control-sm {
|
|||
|
||||
/* Empurra o conteúdo pra baixo do header fixo */
|
||||
.app-main.has-header {
|
||||
position: relative;
|
||||
padding-top: 84px; /* altura segura p/ header (mobile/desktop) */
|
||||
background:
|
||||
radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.1), transparent 60%),
|
||||
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.06), transparent 60%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
|
@ -92,6 +97,21 @@ select.form-control-sm {
|
|||
}
|
||||
}
|
||||
|
||||
/* Ajuste para monitores grandes: elimina o "vão" visual entre header e corpo. */
|
||||
@media (min-width: 1400px) {
|
||||
.app-main.has-header {
|
||||
padding-top: 72px;
|
||||
}
|
||||
|
||||
.container-geral,
|
||||
.container-geral-responsive,
|
||||
.container-fat,
|
||||
.container-mureg,
|
||||
.container-troca {
|
||||
margin-top: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* 🚀 GLOBAL FIX: Proporção Horizontal e Vertical */
|
||||
/* ========================================================== */
|
||||
|
|
@ -143,7 +163,14 @@ select.form-control-sm {
|
|||
.users-page,
|
||||
.fat-page,
|
||||
.mureg-page,
|
||||
.troca-page {
|
||||
.troca-page,
|
||||
.historico-page,
|
||||
.perfil-page,
|
||||
.dashboard-page,
|
||||
.chips-page,
|
||||
.parcelamentos-page,
|
||||
.resumo-page,
|
||||
.create-user-page {
|
||||
overflow-y: auto !important;
|
||||
height: auto !important;
|
||||
display: block !important;
|
||||
|
|
@ -280,3 +307,23 @@ app-header .modal-card .btn-secondary:hover {
|
|||
}
|
||||
}
|
||||
|
||||
/* Remove separators inside search inputs (icon / text / clear button). */
|
||||
.input-group.search-group {
|
||||
> .input-group-text,
|
||||
> .form-control,
|
||||
> .btn,
|
||||
> .btn-clear {
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
> :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.invalid-tooltip) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
> .form-control:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue