Backup do LineGestão

This commit is contained in:
Eduardo 2026-02-02 08:23:30 -03:00
parent 4d357a4530
commit a8e40b640d
33 changed files with 3524 additions and 726 deletions

View File

@ -14,6 +14,7 @@ import { TrocaNumero } from './pages/troca-numero/troca-numero';
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';
export const routes: Routes = [
{ path: '', component: Home },
@ -28,6 +29,7 @@ export const routes: Routes = [
{ 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] },
// ✅ rota correta
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] },

View File

@ -35,6 +35,7 @@ export class AppComponent {
'/trocanumero',
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
'/notificacoes',
'/chips-controle-recebidos',
];
constructor(

View File

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

View File

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

View File

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

View File

@ -64,8 +64,13 @@
(click)="markNotificationRead(n)"
>
<div class="notif-icon-area">
<div class="icon-circle" [class.danger]="n.tipo === 'Vencido'" [class.warn]="n.tipo === 'AVencer'">
<i class="bi" [class.bi-x-lg]="n.tipo === 'Vencido'" [class.bi-clock-history]="n.tipo === 'AVencer'"></i>
<div class="icon-circle"
[class.danger]="n.tipo === 'Vencido'"
[class.warn]="n.tipo === 'AVencer'">
<i class="bi"
[class.bi-x-lg]="n.tipo === 'Vencido'"
[class.bi-clock-history]="n.tipo === 'AVencer'"
[class.bi-info-circle]="n.tipo !== 'Vencido' && n.tipo !== 'AVencer'"></i>
</div>
</div>
@ -106,12 +111,15 @@
<div class="options-dropdown" *ngIf="optionsOpen">
<div class="dropdown-arrow"></div>
<button type="button" class="options-item" (click)="closeOptions()">
<i class="bi bi-person-circle"></i> Perfil
</button>
<div class="divider"></div>
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openCreateUserModal()">
<i class="bi bi-person-plus"></i> Criar novo usuário
</button>
<div class="divider"></div>
<button type="button" class="options-item" (click)="closeOptions()">
<i class="bi bi-person-circle"></i> Perfil
<button type="button" class="options-item" *ngIf="isAdmin" (click)="openManageUsersModal()">
<i class="bi bi-people"></i> Editar usuário
</button>
<div class="divider"></div>
<button type="button" class="options-item danger" (click)="logout()">
@ -153,7 +161,7 @@
</div>
<div class="modal-body">
<div class="form-alert error" *ngIf="createUserForbidden">
Voce nao tem permissao para criar usuarios.
Você não tem permissão para criar usuários.
</div>
<div class="form-alert error" *ngIf="!createUserForbidden && createUserErrors.length">
<strong>Confira os campos:</strong>
@ -170,38 +178,32 @@
<form class="user-form" id="createUserForm" [formGroup]="createUserForm" (ngSubmit)="submitCreateUser()">
<div class="form-field" [class.has-error]="hasFieldError('nome') || (createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid)">
<label for="modalNome">Nome</label>
<input id="modalNome" type="text" placeholder="Nome completo" formControlName="nome" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid">Nome obrigatorio.</small>
<input id="modalNome" type="text" placeholder="Nome completo" formControlName="nome" />
<small class="field-error" *ngIf="createUserForm.get('nome')?.touched && createUserForm.get('nome')?.invalid">Nome obrigatório.</small>
<small class="field-error" *ngIf="getFieldErrors('nome').length">{{ getFieldErrors('nome')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('email') || (createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid)">
<label for="modalEmail">Email</label>
<input id="modalEmail" type="email" placeholder="nome@empresa.com" formControlName="email" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid">Email invalido.</small>
<input id="modalEmail" type="email" placeholder="nome@empresa.com" formControlName="email" />
<small class="field-error" *ngIf="createUserForm.get('email')?.touched && createUserForm.get('email')?.invalid">Email inválido.</small>
<small class="field-error" *ngIf="getFieldErrors('email').length">{{ getFieldErrors('email')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('senha') || (createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid)">
<label for="modalSenha">Senha</label>
<input id="modalSenha" type="password" placeholder="Defina uma senha segura" formControlName="senha" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid">Senha invalida.</small>
<input id="modalSenha" type="password" placeholder="Defina uma senha segura" formControlName="senha" />
<small class="field-error" *ngIf="createUserForm.get('senha')?.touched && createUserForm.get('senha')?.invalid">Senha inválida.</small>
<small class="field-error" *ngIf="getFieldErrors('senha').length">{{ getFieldErrors('senha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('confirmarSenha') || (createUserForm.get('confirmarSenha')?.touched && createUserForm.get('confirmarSenha')?.invalid) || (passwordMismatch && createUserForm.get('confirmarSenha')?.touched)">
<label for="modalConfirmar">Confirmar Senha</label>
<input id="modalConfirmar" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" [disabled]="createUserSubmitting" />
<small class="field-error" *ngIf="passwordMismatch && createUserForm.get('confirmarSenha')?.touched">As senhas nao conferem.</small>
<input id="modalConfirmar" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" />
<small class="field-error" *ngIf="passwordMismatch && createUserForm.get('confirmarSenha')?.touched">As senhas não conferem.</small>
<small class="field-error" *ngIf="getFieldErrors('confirmarSenha').length">{{ getFieldErrors('confirmarSenha')[0] }}</small>
</div>
<div class="form-field" [class.has-error]="hasFieldError('permissao') || (createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid)">
<label for="modalPermissoes">Permissoes</label>
<select id="modalPermissoes" formControlName="permissao" [disabled]="createUserSubmitting">
<option value="" selected>Selecione o nivel</option>
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="operador">Operador</option>
<option value="leitura">Leitura</option>
</select>
<small class="field-error" *ngIf="createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid">Selecione uma permissao.</small>
<label for="modalPermissoes">Permissões</label>
<app-select id="modalPermissoes" formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
<small class="field-error" *ngIf="createUserForm.get('permissao')?.touched && createUserForm.get('permissao')?.invalid">Selecione uma permissão.</small>
<small class="field-error" *ngIf="getFieldErrors('permissao').length">{{ getFieldErrors('permissao')[0] }}</small>
</div>
</form>
@ -215,6 +217,170 @@
</div>
</div>
<div class="modal-overlay" *ngIf="manageUsersOpen" (click)="closeManageUsersModal()"></div>
<div class="modal-card manage-users-modal" *ngIf="manageUsersOpen" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Gestão de Usuários</h3>
<button type="button" class="btn-icon close-x" (click)="closeManageUsersModal()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body manage-body">
<div class="manage-left">
<div class="manage-search">
<div class="search-input-wrapper">
<i class="bi bi-search"></i>
<input type="text" placeholder="Buscar por nome ou email..." [(ngModel)]="manageSearch" (keyup.enter)="onManageSearch()" />
</div>
</div>
<div class="manage-table-wrap custom-scroll">
<div class="loading-state" *ngIf="manageUsersLoading">
<div class="spinner-border text-primary" role="status"></div>
</div>
<table class="manage-table" *ngIf="!manageUsersLoading && manageUsers.length">
<thead>
<tr>
<th style="width: 40%;">Usuário</th>
<th style="width: 25%;" class="text-center">Permissão</th>
<th style="width: 15%;" class="text-center">Status</th>
<th style="width: 20%;" class="text-center">Ações</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let u of manageUsers" [class.selected]="editUserTarget?.id === u.id" (click)="openEditUser(u)">
<td>
<div class="user-cell">
<div class="avatar-mini">{{ u.nome.charAt(0).toUpperCase() }}</div>
<div class="user-info">
<span class="u-name" title="{{ u.nome }}">{{ u.nome }}</span>
<span class="u-email" title="{{ u.email }}">{{ u.email }}</span>
</div>
</div>
</td>
<td class="text-center">
<span class="badge-role">{{ u.permissao }}</span>
</td>
<td class="text-center">
<span class="status-dot" [class.off]="u.ativo === false" title="{{ u.ativo === false ? 'Inativo' : 'Ativo' }}"></span>
</td>
<td class="text-center">
<div class="actions-group">
<button type="button" class="btn-action edit" title="Editar" (click)="openEditUser(u); $event.stopPropagation()">
<i class="bi bi-pencil-fill"></i>
</button>
<button type="button" class="btn-action delete" title="Desativar/Excluir" (click)="confirmDeleteUser(u); $event.stopPropagation()">
<i class="bi bi-trash-fill"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="empty-state-list" *ngIf="!manageUsersLoading && !manageUsers.length">
<i class="bi bi-search"></i>
<p>Nenhum usuário encontrado.</p>
</div>
</div>
<div class="list-footer">
<div class="page-info">
{{ managePage }} / {{ manageTotalPages }}
</div>
<div class="pagination">
<button type="button" class="btn-ghost icon-only" (click)="manageGoToPage(managePage - 1)" [disabled]="managePage <= 1">
<i class="bi bi-chevron-left"></i>
</button>
<button type="button" class="btn-ghost icon-only" (click)="manageGoToPage(managePage + 1)" [disabled]="managePage >= manageTotalPages">
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
<div class="manage-right-wrapper">
<div class="manage-right" *ngIf="editUserTarget as target">
<div class="edit-header-info">
<div class="avatar-large">{{ target.nome.charAt(0).toUpperCase() }}</div>
<div class="info-text">
<h4>{{ target.nome }}</h4>
<span>Editando perfil</span>
</div>
</div>
<div class="form-alert error" *ngIf="editUserErrors.length">
<ul><li *ngFor="let err of editUserErrors">{{ err.message }}</li></ul>
</div>
<div class="form-alert success" *ngIf="editUserSuccess">{{ editUserSuccess }}</div>
<form class="user-form refined-form" id="editUserHeaderForm" [formGroup]="editUserForm" (ngSubmit)="submitEditUser()">
<div class="form-row">
<div class="form-field">
<label for="editHeaderNome">Nome Completo</label>
<input id="editHeaderNome" type="text" formControlName="nome" />
</div>
</div>
<div class="form-field">
<label for="editHeaderEmail">Email Corporativo</label>
<input id="editHeaderEmail" type="email" formControlName="email" />
</div>
<div class="form-row two-col">
<div class="form-field">
<label for="editHeaderSenha">Nova Senha</label>
<input id="editHeaderSenha" type="password" placeholder="••••••" formControlName="senha" autocomplete="new-password" />
</div>
<div class="form-field">
<label for="editHeaderConfirmar">Confirmar</label>
<input id="editHeaderConfirmar" type="password" placeholder="••••••" formControlName="confirmarSenha" autocomplete="new-password" />
</div>
</div>
<div class="form-row two-col align-end">
<div class="form-field">
<label for="editHeaderPermissao">Nível de Acesso</label>
<app-select id="editHeaderPermissao" formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
</div>
<div class="form-field">
<label class="mb-1 d-block">Status da Conta</label>
<div class="toggle-wrapper">
<label class="switch">
<input id="editHeaderAtivo" type="checkbox" formControlName="ativo" />
<span class="slider round"></span>
</label>
<span class="toggle-status" [class.active]="editUserForm.get('ativo')?.value">
{{ editUserForm.get('ativo')?.value ? 'Ativo' : 'Inativo' }}
</span>
</div>
</div>
</div>
</form>
<div class="manage-actions-footer">
<button type="button" class="btn-ghost" (click)="cancelEditUser()" [disabled]="editUserSubmitting">Cancelar</button>
<button type="submit" form="editUserHeaderForm" class="btn-primary" [disabled]="editUserSubmitting || !editUserTarget">
<span *ngIf="!editUserSubmitting">Salvar Alterações</span>
<span *ngIf="editUserSubmitting"><div class="spinner-border spinner-border-sm"></div></span>
</button>
</div>
</div>
<div class="manage-right placeholder" *ngIf="!editUserTarget">
<div class="placeholder-content">
<div class="placeholder-icon">
<i class="bi bi-person-gear"></i>
</div>
<h3>Editar Usuário</h3>
<p>Selecione um usuário na lista para visualizar e editar os detalhes.</p>
</div>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 2000;">
<div class="toast notification-toast" #notifToast>
<div class="toast-header">
@ -262,13 +428,11 @@
<a routerLink="/vigencia" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-calendar2-check-fill"></i> <span>Vigência</span>
</a>
<a routerLink="/chips-controle-recebidos" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-archive-fill"></i> <span>Chips Virgens e Recebidos</span>
</a>
<a routerLink="/trocanumero" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-arrow-left-right"></i> <span>Troca de número</span>
</a>
</div>
</aside>

View File

@ -1,578 +1,457 @@
/* Variáveis de apoio */
@use 'sass:color';
/* Variáveis */
$primary: #1c38c9;
$primary-hover: #152ca0;
$danger: #ef4444;
$warning: #f59e0b;
$success: #10b981;
$text-main: #111827;
$text-muted: #6b7280;
$bg-light: #f3f4f6;
$border-color: rgba(0,0,0,0.06);
$bg-light: #f9fafb;
$border-color: #e5e7eb;
/* Utils */
* { box-sizing: border-box; }
.custom-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
.custom-scroll::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 10px; }
.custom-scroll::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* HEADER PRINCIPAL */
.app-header {
position: fixed;
top: 0; left: 0; width: 100%;
z-index: 1000;
padding: 14px 0;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(0,0,0,0.05);
transition: all 0.3s ease;
&.scrolled {
padding: 10px 0;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
}
position: fixed; top: 0; left: 0; width: 100%; z-index: 1000;
padding: 14px 0; background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0,0,0,0.05); transition: all 0.3s ease;
&.scrolled { padding: 10px 0; box-shadow: 0 4px 20px rgba(0,0,0,0.03); }
}
.header-inner {
display: flex; align-items: center; justify-content: space-between;
}
.logged-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
/* --- LOGO & MENU --- */
.header-inner { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.logged-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; }
.left-logged { display: flex; align-items: center; gap: 16px; }
.btn-icon {
background: transparent;
border: none;
width: 40px; height: 40px;
border-radius: 50%;
display: grid; place-items: center;
cursor: pointer;
transition: background 0.2s;
color: $text-main;
background: transparent; border: none; width: 40px; height: 40px; border-radius: 50%;
display: grid; place-items: center; cursor: pointer; transition: background 0.2s; color: $text-main;
&:hover { background: rgba(0,0,0,0.04); }
i { font-size: 20px; }
}
.logo-area {
display: flex; align-items: center; gap: 10px;
text-decoration: none; color: #111827;
display: flex; align-items: center; gap: 10px; text-decoration: none; color: #111827;
.logo-icon {
width: 36px; height: 36px;
background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
color: #fff;
border-radius: 50%;
display: grid; place-items: center;
font-size: 18px;
box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
width: 36px; height: 36px; background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
color: #fff; border-radius: 50%; display: grid; place-items: center; font-size: 18px; box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
}
.logo-text {
font-size: 19px; font-weight: 700; letter-spacing: -0.5px;
.highlight {
background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
}
.logged-actions {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
}
/* --- NOTIFICAÇÕES (Dropdown) --- */
.notifications-menu { position: relative; }
.btn-bell {
position: relative;
&.has-unread {
color: $primary;
background: rgba(28, 56, 201, 0.06);
}
.badge-pulse {
position: absolute;
top: 10px; right: 10px;
width: 8px; height: 8px;
background: $danger;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 0 rgba($danger, 0.7);
animation: pulse-red 2s infinite;
.highlight { background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
}
}
@keyframes pulse-red {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba($danger, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0); }
.nav-links { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 1; }
.nav-links .nav-link {
display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s;
&:hover { color: $primary; }
}
.header-actions { display: flex; align-items: center; }
.btn-login-header {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px;
border: 1px solid rgba(28, 56, 201, 0.18); background: #fff; color: $primary; font-weight: 700; font-size: 13px; text-decoration: none; transition: all 0.2s;
&:hover { transform: translateY(-1px); background: rgba(28, 56, 201, 0.04); box-shadow: 0 4px 12px rgba(28, 56, 201, 0.15); }
}
.notifications-dropdown {
position: absolute;
top: calc(100% + 12px); right: -10px;
width: 340px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.04);
z-index: 1200;
transform-origin: top right;
animation: slideDown 0.2s ease-out;
overflow: hidden;
}
@media (max-width: 900px) { .nav-links { display: none; } }
.logged-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.notifications-head {
padding: 16px;
border-bottom: 1px solid $border-color;
display: flex; align-items: center; justify-content: space-between;
background: #fff;
.head-title {
font-weight: 700; font-size: 15px; color: $text-main;
display: flex; align-items: center; gap: 8px;
}
.badge-count {
background: $danger; color: #fff;
font-size: 10px; padding: 2px 6px;
border-radius: 99px; font-weight: 800;
}
.see-all {
font-size: 12px; font-weight: 600; color: $primary;
text-decoration: none;
&:hover { text-decoration: underline; }
@media (min-width: 1200px) {
.header-inner.container {
max-width: none;
width: 100%;
padding-left: 28px;
padding-right: 28px;
}
}
.notifications-body {
max-height: 380px;
overflow-y: auto;
/* DROPDOWNS */
.notifications-menu, .options-menu { position: relative; }
.notifications-dropdown, .options-dropdown {
position: absolute; top: calc(100% + 12px); right: -10px; width: 340px; background: #fff; border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05); z-index: 1200; transform-origin: top right;
animation: slideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1); overflow: hidden;
}
.options-dropdown { width: 220px; right: 0; padding: 6px; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
/* Scrollbar Bonito */
.custom-scroll::-webkit-scrollbar { width: 5px; }
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
.custom-scroll::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 10px; }
.custom-scroll::-webkit-scrollbar-thumb:hover { background: #d1d5db; }
.notifications-empty {
padding: 40px 20px;
text-align: center;
color: $text-muted;
.empty-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.5; }
p { margin: 0; font-size: 13px; font-weight: 500; }
}
/* Item da Notificação */
.notification-item {
display: flex; gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
cursor: pointer;
transition: background 0.15s;
position: relative;
&:hover { background: $bg-light; }
&:last-child { border-bottom: none; }
/* Estilo Não Lido */
&.unread {
background: rgba(28, 56, 201, 0.02);
&:hover { background: rgba(28, 56, 201, 0.05); }
.notif-title { color: $text-main; font-weight: 700; }
.status-dot {
width: 8px; height: 8px;
background: $primary;
border-radius: 50%;
display: block;
}
}
}
.icon-circle {
width: 36px; height: 36px;
border-radius: 10px;
display: grid; place-items: center;
background: #f3f4f6; color: $text-muted;
font-size: 16px;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); }
}
.notif-content { flex: 1; min-width: 0; }
.notif-header {
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px;
}
.notif-title { font-size: 13px; color: $text-main; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.notif-date { font-size: 11px; color: $text-muted; }
.notif-desc {
margin: 0; font-size: 12px; color: $text-muted;
line-height: 1.4;
}
.notif-meta {
margin-top: 4px; font-size: 11px; color: rgba(0,0,0,0.4);
display: flex; align-items: center; gap: 4px;
}
.notif-status {
display: flex; align-items: center; justify-content: center;
padding-left: 4px;
}
/* --- USER OPTIONS (Dropdown) --- */
.user-trigger {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px 4px 4px;
background: #fff;
border: 1px solid $border-color;
border-radius: 99px;
cursor: pointer;
transition: all 0.2s;
&:hover { border-color: rgba(0,0,0,0.2); box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.user-avatar {
width: 32px; height: 32px;
background: $bg-light;
border-radius: 50%;
display: grid; place-items: center;
color: $text-muted;
display: flex; align-items: center; gap: 8px; padding: 4px; background: #fff; border: 1px solid $border-color; border-radius: 99px; cursor: pointer; transition: all 0.2s;
&:hover, &[aria-expanded="true"] { border-color: $primary; box-shadow: 0 0 0 2px rgba(28, 56, 201, 0.1); }
.user-avatar { width: 32px; height: 32px; background: $bg-light; border-radius: 50%; display: grid; place-items: center; color: $text-muted; }
.chevron { font-size: 10px; color: $text-muted; margin: 0 6px 0 2px; }
}
.chevron { font-size: 10px; color: $text-muted; margin-right: 4px; }
}
.options-menu {
position: relative; /* Essencial: Torna este o ponto de referência */
display: flex;
align-items: center;
}
.options-dropdown {
position: absolute;
top: 100%; /* Cola no final do botão */
right: 0; /* Alinha à direita do botão */
margin-top: 10px; /* Dá o espaçamento visual */
width: 200px; /* Sugestão: um pouco mais largo para caber bem os textos */
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.04);
padding: 6px;
z-index: 1200;
/* Animação suave (Opcional) */
transform-origin: top right;
animation: slideDown 0.2s ease-out;
.options-item {
width: 100%; text-align: left;
padding: 8px 12px;
background: transparent; border: none;
border-radius: 8px;
font-size: 13px; font-weight: 500; color: $text-main;
display: flex; align-items: center; gap: 10px;
cursor: pointer;
width: 100%; text-align: left; padding: 10px 12px; background: transparent; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; color: $text-main; display: flex; align-items: center; gap: 10px; cursor: pointer;
&:hover { background: $bg-light; }
&.danger { color: $danger; &:hover { background: rgba($danger, 0.05); } }
}
.divider { height: 1px; background: $border-color; margin: 4px 0; }
/* NOTIFICAÇÕES */
.btn-bell {
&.has-unread { color: $primary; background: rgba(28, 56, 201, 0.06); }
.badge-pulse {
position: absolute; top: 10px; right: 10px; width: 8px; height: 8px; background: $danger; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 0 rgba($danger, 0.7); animation: pulse 2s infinite;
}
}
@keyframes pulse { 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0.7); } 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba($danger, 0); } 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba($danger, 0); } }
.notifications-head { padding: 16px; border-bottom: 1px solid $border-color; display: flex; justify-content: space-between; align-items: center; .head-title { font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 6px; } .see-all { font-size: 12px; color: $primary; text-decoration: none; font-weight: 600; } }
.notifications-body { max-height: 360px; overflow-y: auto; }
.notifications-empty { padding: 32px; text-align: center; color: $text-muted; .empty-icon { font-size: 24px; margin-bottom: 8px; } }
.notification-item {
display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid $border-color; cursor: pointer;
&:hover { background: $bg-light; }
&.unread { background: rgba(28, 56, 201, 0.03); .notif-title { font-weight: 700; } .status-dot { width: 6px; height: 6px; background: $primary; border-radius: 50%; } }
.icon-circle {
width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: #f3f4f6; color: $text-muted; font-size: 16px;
&.danger { background-color: #fee2e2; color: #dc2626; }
&.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; }
}
/* --- MODAL NOVO USUÁRIO --- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 1400;
}
/* MODAIS GERAIS */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px); z-index: 1400; animation: fadeIn 0.2s; }
.modal-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 32px));
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(500px, calc(100vw - 32px)); background: #fff; border-radius: 16px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); z-index: 1450; display: flex; flex-direction: column; animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes scaleIn { from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid $border-color; h3 { margin: 0; font-size: 16px; font-weight: 600; color: $text-main; } }
.close-x { width: 32px; height: 32px; border-radius: 6px; &:hover { background: #f3f4f6; } }
.modal-body { padding: 20px; }
.modal-actions { padding: 16px 20px; display: flex; justify-content: flex-end; gap: 10px; background: $bg-light; border-radius: 0 0 16px 16px; }
.form-field {
display: grid; gap: 6px; margin-bottom: 16px;
label { font-size: 13px; font-weight: 500; color: $text-main; }
input, select { width: 100%; height: 40px; border-radius: 8px; border: 1px solid #d1d5db; padding: 0 12px; font-size: 14px; transition: all 0.2s; &:focus { outline: none; border-color: $primary; box-shadow: 0 0 0 3px rgba(28, 56, 201, 0.1); } }
}
.field-error { font-size: 12px; color: $danger; margin-top: 2px; }
.form-alert {
padding: 12px; border-radius: 8px; font-size: 13px; margin-bottom: 16px;
&.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
&.success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
ul { margin: 4px 0 0; padding-left: 16px; }
}
.btn-primary, .btn-secondary, .btn-ghost { height: 38px; padding: 0 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; display: inline-flex; align-items: center; justify-content: center; }
.btn-primary { background: $primary; color: #fff; &:hover:not(:disabled) { background: $primary-hover; } &:disabled { opacity: 0.7; cursor: not-allowed; } }
.btn-secondary { background: #fff; border: 1px solid $border-color; color: $text-main; &:hover { background: $bg-light; } }
.btn-ghost { background: transparent; color: $text-muted; &:hover { background: rgba(0,0,0,0.05); color: $text-main; } }
/* ==========================================================================
MODAL EDITAR USUÁRIO - LAYOUT FINAL
========================================================================== */
.modal-card.manage-users-modal {
width: min(1200px, 95vw);
height: min(650px, 90vh);
}
.manage-body {
padding: 0;
display: grid;
grid-template-columns: 50% 50%;
height: 100%;
overflow: hidden;
}
/* Lado Esquerdo */
.manage-left {
display: flex; flex-direction: column;
border-right: 1px solid $border-color;
background: #fff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
z-index: 1450;
display: flex;
flex-direction: column;
min-width: 0;
}
.manage-search {
padding: 12px 16px; border-bottom: 1px solid $border-color;
.search-input-wrapper {
position: relative;
i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: $text-muted; }
input { width: 100%; height: 36px; padding-left: 36px; padding-right: 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: $bg-light; font-size: 13px; &:focus { background: #fff; border-color: $primary; outline: none; } }
}
}
.manage-table-wrap {
flex: 1; overflow-y: auto; position: relative; overflow-x: hidden;
}
.manage-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
thead {
position: sticky; top: 0; background: #fff; z-index: 10;
th {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
color: $text-muted; font-weight: 600; padding: 10px 16px;
border-bottom: 1px solid $border-color; background: #f9fafb;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
/* Classes de alinhamento no TH */
&.text-center { text-align: center; }
&.text-end { text-align: right; }
}
}
tbody tr {
border-bottom: 1px solid $border-color; transition: background 0.15s; cursor: pointer;
&:hover { background: #f9fafb; }
&.selected { background: rgba(28, 56, 201, 0.04); border-left: 3px solid $primary; }
td {
padding: 12px 16px; vertical-align: middle;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
/* Classes de alinhamento no TD */
&.text-center { text-align: center; overflow: visible; }
&.text-end { text-align: right; overflow: visible; }
}
}
}
.user-cell {
display: flex; align-items: center; gap: 10px; max-width: 100%;
.avatar-mini {
width: 32px; height: 32px; min-width: 32px;
background: $primary; color: #fff; border-radius: 50%;
display: grid; place-items: center; font-weight: 600; font-size: 12px;
}
.user-info {
display: flex; flex-direction: column; min-width: 0;
.u-name { font-size: 13px; font-weight: 500; color: $text-main; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.u-email { font-size: 11px; color: $text-muted; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
}
.badge-role { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; background: #eef2ff; color: $primary; border: 1px solid rgba(28, 56, 201, 0.1); }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: $success; box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); &.off { background: $text-muted; box-shadow: none; opacity: 0.5; } }
.actions-group {
display: flex; gap: 4px;
/* Centraliza os botões quando a célula é text-center */
justify-content: center;
.btn-action {
width: 28px; height: 28px; border-radius: 6px; border: none; background: transparent; color: $text-muted; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s; cursor: pointer;
&:hover { background: #e5e7eb; color: $text-main; }
&.edit:hover { color: $primary; background: rgba(28, 56, 201, 0.1); }
&.delete:hover { color: $danger; background: rgba(239, 68, 68, 0.1); }
}
}
.empty-state-list { padding: 40px 20px; text-align: center; color: $text-muted; i { font-size: 24px; opacity: 0.5; display: block; margin-bottom: 8px; } p { font-size: 13px; margin: 0; } }
.loading-state { padding: 40px; text-align: center; }
.list-footer {
padding: 10px 16px; border-top: 1px solid $border-color; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: $text-muted; background: #fff;
.pagination { display: flex; gap: 4px; }
.icon-only { width: 28px; height: 28px; padding: 0; }
}
/* Lado Direito */
.manage-right-wrapper { background: #fff; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
.manage-right { padding: 32px 40px; flex: 1; display: flex; flex-direction: column; }
.edit-header-info {
display: flex; align-items: center; gap: 16px; margin-bottom: 24px;
.avatar-large { width: 56px; height: 56px; background: linear-gradient(135deg, $primary, #4f46e5); color: #fff; font-size: 20px; font-weight: 600; border-radius: 50%; display: grid; place-items: center; box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2); }
.info-text h4 { margin: 0; font-size: 18px; color: $text-main; }
.info-text span { font-size: 13px; color: $text-muted; }
}
/* AQUI: Placeholder CORRIGIDO */
.manage-right.placeholder {
align-items: center; justify-content: center; text-align: center; height: 100%; padding: 32px;
background: #fff; /* Fundo branco explícito */
cursor: default; /* Remove qualquer cursor de loading herdado */
.placeholder-content {
max-width: 320px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
.placeholder-icon {
font-size: 64px;
color: $text-muted;
opacity: 0.2; /* Estilo marca d'água */
margin-bottom: 16px;
}
h3 {
font-size: 16px;
font-weight: 600;
color: $text-main;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: $text-muted;
line-height: 1.5;
font-weight: 400;
margin: 0;
}
}
}
.refined-form { display: flex; flex-direction: column; gap: 16px; .form-row { display: flex; gap: 16px; &.two-col { display: grid; grid-template-columns: 1fr 1fr; } &.align-end { align-items: end; } } }
.toggle-wrapper { display: flex; align-items: center; gap: 12px; height: 40px; .toggle-status { font-size: 13px; color: $text-muted; font-weight: 500; &.active { color: $success; } } }
.switch {
position: relative; display: inline-block; width: 44px; height: 24px;
input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #e5e7eb; transition: .4s; &.round { border-radius: 24px; } &.round:before { border-radius: 50%; } }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
input:checked + .slider { background-color: $success; }
input:checked + .slider:before { transform: translateX(20px); }
}
.manage-actions-footer { margin-top: 32px; padding-top: 20px; border-top: 1px solid $border-color; display: flex; justify-content: flex-end; gap: 12px; }
@media (max-width: 900px) {
.manage-body { grid-template-columns: 1fr; overflow-y: auto; }
.manage-left { height: 40%; border-right: none; border-bottom: 1px solid $border-color; }
.manage-right-wrapper { height: 60%; }
.manage-right { padding: 20px; }
.form-row.two-col { grid-template-columns: 1fr; }
.manage-table-wrap { overflow-x: auto; }
}
@media (max-width: 1200px) {
.modal-card.manage-users-modal {
width: min(980px, 92vw);
height: min(600px, 86vh);
}
.modal-card {
width: min(460px, 92vw);
}
}
@media (max-width: 900px) {
.modal-card.manage-users-modal {
width: min(720px, 92vw);
height: min(560px, 86vh);
}
.modal-card {
width: min(420px, 92vw);
}
}
@media (max-width: 640px) {
.modal-card.manage-users-modal {
width: min(520px, 94vw);
height: min(520px, 84vh);
}
.modal-card {
width: min(360px, 94vw);
}
}
/* ==========================================================================
AJUSTES PARA NOTEBOOKS / TELAS MENORES (SOLICITADO)
========================================================================== */
/* Adicionado max-width: 1440px e max-height: 800px para pegar notebooks padrão */
@media (max-width: 1440px), (max-height: 800px) {
/* Modal Genérico (Novo Usuário) - Compacto */
.modal-card {
/* Limita a altura para não sair da tela e habilita scroll interno */
max-height: 95vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid $border-color;
h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: $text-main;
}
padding: 12px 16px;
h3 { font-size: 15px; }
}
.modal-body {
padding: 18px 20px 10px;
padding: 16px;
/* Essencial para que o conteúdo não estoure o modal quando encolhido */
overflow-y: auto;
}
.modal-actions {
padding: 12px 16px;
}
/* Compactar formulários */
.form-field {
margin-bottom: 12px;
label { font-size: 12px; }
input, select {
height: 36px;
font-size: 13px;
}
}
.form-alert {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
padding: 10px;
margin-bottom: 12px;
line-height: 1.4;
ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
}
&.error {
background: rgba($danger, 0.08);
color: darken($danger, 5%);
border: 1px solid rgba($danger, 0.25);
/* Modal de Gestão (Editar Usuário) - MUITO COMPACTO (Solicitado) */
.modal-card.manage-users-modal {
/* Reduzido consideravelmente */
height: min(500px, 80vh);
width: min(900px, 92vw);
}
&.success {
background: rgba(#22c55e, 0.1);
color: #15803d;
border: 1px solid rgba(#22c55e, 0.25);
.manage-right {
padding: 24px;
}
.edit-header-info {
margin-bottom: 16px;
.avatar-large {
width: 48px;
height: 48px;
font-size: 16px;
}
.info-text h4 {
font-size: 16px;
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
.refined-form {
gap: 12px;
padding: 0 20px 18px;
}
.close-x {
width: 34px;
height: 34px;
}
.modal-card .user-form {
display: grid;
gap: 14px;
}
.modal-card .form-field {
display: grid;
gap: 6px;
label {
font-size: 13px;
font-weight: 600;
color: $text-main;
}
input,
select {
height: 42px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 14px;
color: $text-main;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus {
outline: none;
border-color: #2f6bff;
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15);
}
&.has-error input,
&.has-error select {
border-color: $danger;
box-shadow: 0 0 0 3px rgba($danger, 0.12);
}
}
.field-error {
font-size: 11px;
color: $danger;
}
.modal-card .btn-primary,
.modal-card .btn-secondary {
height: 40px;
min-width: 110px;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.modal-card .btn-primary {
background: #2f6bff;
color: #fff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2);
}
.modal-card .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.modal-card .btn-secondary {
background: #e2e8f0;
color: $text-main;
}
.modal-card .btn-primary:hover,
.modal-card .btn-secondary:hover {
transform: translateY(-1px);
}
@media (max-width: 768px) {
.modal-card {
width: min(520px, calc(100vw - 24px));
}
.modal-actions {
flex-direction: column;
align-items: stretch;
}
.modal-card .btn-primary,
.modal-card .btn-secondary {
width: 100%;
}
}
/* --- MENU LATERAL --- */
.menu-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.25);
z-index: 1050;
}
/* SIDE MENU */
.menu-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1050; }
.side-menu {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 280px;
background: #fff;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.12);
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 1100;
display: flex;
flex-direction: column;
position: fixed; top: 0; left: 0; height: 100vh; width: 260px; background: #fff; box-shadow: 4px 0 20px rgba(0,0,0,0.1); transform: translateX(-100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 1100; display: flex; flex-direction: column;
&.open { transform: translateX(0); }
}
.side-menu.open {
transform: translateX(0);
}
.side-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 16px;
border-bottom: 1px solid $border-color;
}
.side-logo {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: #111827;
}
.side-logo-icon {
width: 34px;
height: 34px;
border-radius: 50%;
background: conic-gradient(from 210deg, #2f6bff, #7c3aed, #ec4899, #f59e0b, #22c55e, #2f6bff);
color: #fff;
display: grid;
place-items: center;
font-size: 16px;
box-shadow: 0 6px 14px rgba(47, 107, 255, 0.2);
}
.side-logo-text {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.4px;
.highlight {
background: linear-gradient(90deg, #2f6bff 0%, #7c3aed 55%, #ec4899 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.side-menu-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid $border-color; }
.close-btn {
background: transparent;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
box-shadow: none;
width: 32px;
height: 32px;
border-radius: 0;
display: grid;
place-items: center;
cursor: pointer;
color: $text-main;
transition: background 0.2s;
&:hover { background: rgba(0, 0, 0, 0.06); }
transition: color 0.2s ease;
&:hover { color: $primary; }
}
.side-menu-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.side-logo { display: flex; align-items: center; gap: 10px; text-decoration: none; color: $text-main; font-weight: 700; .side-logo-icon { width: 32px; height: 32px; background: $primary; color: #fff; border-radius: 50%; display: grid; place-items: center; } }
.side-menu-body { padding: 16px; display: flex; flex-direction: column; gap: 4px; }
.side-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
text-decoration: none;
color: $text-main;
font-weight: 600;
font-size: 14px;
transition: background 0.2s;
padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px;
&:hover { background: $bg-light; }
&.active { background: rgba(28, 56, 201, 0.1); color: $primary; }
&.active { background: rgba(28, 56, 201, 0.08); color: $primary; }
}

View File

@ -6,13 +6,14 @@ import { filter } from 'rxjs/operators';
import { AuthService } from '../../services/auth.service';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
import { UsersService, CreateUserPayload, ApiFieldError } from '../../services/users.service';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../custom-select/custom-select';
@Component({
selector: 'app-header',
standalone: true,
imports: [RouterLink, CommonModule, ReactiveFormsModule],
imports: [RouterLink, CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent],
templateUrl: './header.html',
styleUrls: ['./header.scss'],
})
@ -23,6 +24,7 @@ export class Header {
optionsOpen = false;
notificationsOpen = false;
createUserOpen = false;
manageUsersOpen = false;
isLoggedHeader = false;
isHome = false;
isAdmin = false;
@ -37,6 +39,25 @@ export class Header {
createUserErrors: ApiFieldError[] = [];
createUserForbidden = false;
createUserSuccess = '';
readonly permissionOptions = [
{ value: 'admin', label: 'Administrador' },
{ value: 'gestor', label: 'Gestor' },
];
manageUsersLoading = false;
manageUsersErrors: ApiFieldError[] = [];
manageUsersSuccess = '';
manageUsers: any[] = [];
manageSearch = '';
managePage = 1;
managePageSize = 10;
manageTotal = 0;
editUserForm: FormGroup;
editUserSubmitting = false;
editUserErrors: ApiFieldError[] = [];
editUserSuccess = '';
editUserTarget: any | null = null;
private readonly loggedPrefixes = [
'/geral',
@ -45,9 +66,10 @@ export class Header {
'/dadosusuarios',
'/vigencia',
'/trocanumero',
'/dashboard', // ✅ ADICIONADO
'/dashboard',
'/notificacoes',
'/novo-usuario',
'/chips-controle-recebidos',
];
constructor(
@ -69,11 +91,18 @@ export class Header {
{ validators: this.passwordsMatchValidator }
);
// ✅ resolve no carregamento inicial
this.editUserForm = this.fb.group({
nome: [''],
email: [''],
senha: [''],
confirmarSenha: [''],
permissao: [''],
ativo: [true],
});
this.syncHeaderState(this.router.url);
this.syncPermissions();
// ✅ resolve em toda navegação
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((event) => {
@ -139,6 +168,19 @@ export class Header {
this.resetCreateUserState();
}
openManageUsersModal() {
if (!this.isAdmin) return;
this.manageUsersOpen = true;
this.closeOptions();
this.resetManageUsersState();
this.fetchManageUsers(1);
}
closeManageUsersModal() {
this.manageUsersOpen = false;
this.resetManageUsersState();
}
toggleNotifications() {
this.notificationsOpen = !this.notificationsOpen;
if (this.notificationsOpen) {
@ -192,6 +234,7 @@ export class Header {
this.closeOptions();
this.closeNotifications();
this.closeCreateUserModal();
this.closeManageUsersModal();
}
acknowledgeNotification(notification: NotificationDto) {
@ -268,6 +311,7 @@ export class Header {
}
this.createUserSubmitting = true;
this.setCreateFormDisabled(true);
this.createUserErrors = [];
this.createUserForbidden = false;
this.createUserSuccess = '';
@ -276,11 +320,13 @@ export class Header {
this.usersService.create(payload).subscribe({
next: (created) => {
this.createUserSubmitting = false;
this.setCreateFormDisabled(false);
this.createUserSuccess = `Usuario ${created.nome} criado com sucesso.`;
this.createUserForm.reset({ permissao: '' });
},
error: (err: HttpErrorResponse) => {
this.createUserSubmitting = false;
this.setCreateFormDisabled(false);
if (err.status === 401 || err.status === 403) {
this.createUserForbidden = true;
return;
@ -298,6 +344,173 @@ export class Header {
});
}
fetchManageUsers(goToPage?: number) {
if (goToPage) this.managePage = goToPage;
this.manageUsersLoading = true;
this.manageUsersErrors = [];
this.manageUsersSuccess = '';
this.usersService
.list({
search: this.manageSearch?.trim() || undefined,
page: this.managePage,
pageSize: this.managePageSize,
})
.subscribe({
next: (res) => {
this.manageUsers = res.items || [];
this.manageTotal = res.total || 0;
this.manageUsersLoading = false;
},
error: () => {
this.manageUsers = [];
this.manageTotal = 0;
this.manageUsersLoading = false;
},
});
}
onManageSearch() {
this.managePage = 1;
this.fetchManageUsers();
}
clearManageSearch() {
this.manageSearch = '';
this.managePage = 1;
this.fetchManageUsers();
}
manageGoToPage(p: number) {
this.managePage = p;
this.fetchManageUsers();
}
get manageTotalPages(): number {
return Math.max(1, Math.ceil((this.manageTotal || 0) / (this.managePageSize || 10)));
}
get managePageNumbers(): number[] {
const total = this.manageTotalPages;
const current = this.managePage;
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;
}
openEditUser(user: any) {
this.editUserTarget = null;
this.editUserErrors = [];
this.editUserSuccess = '';
this.editUserSubmitting = false;
this.setEditFormDisabled(false);
this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
this.usersService.getById(user.id).subscribe({
next: (full) => {
this.editUserTarget = full;
this.editUserForm.reset({
nome: full.nome ?? '',
email: full.email ?? '',
senha: '',
confirmarSenha: '',
permissao: full.permissao ?? '',
ativo: full.ativo ?? true,
});
},
error: () => {
this.editUserErrors = [{ message: 'Erro ao carregar usuario.' }];
},
});
}
cancelEditUser() {
this.editUserTarget = null;
this.editUserErrors = [];
this.editUserSuccess = '';
this.editUserSubmitting = false;
this.setEditFormDisabled(false);
this.editUserForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
}
submitEditUser() {
if (this.editUserSubmitting || !this.editUserTarget) return;
const payload: any = {};
const nome = (this.editUserForm.get('nome')?.value || '').toString().trim();
const email = (this.editUserForm.get('email')?.value || '').toString().trim();
const permissao = (this.editUserForm.get('permissao')?.value || '').toString().trim();
const ativo = !!this.editUserForm.get('ativo')?.value;
if (nome && nome !== (this.editUserTarget.nome || '').trim()) payload.nome = nome;
if (email && email !== (this.editUserTarget.email || '').trim()) payload.email = email;
if (permissao && permissao !== (this.editUserTarget.permissao || '').trim()) payload.permissao = permissao;
if ((this.editUserTarget.ativo ?? true) !== ativo) payload.ativo = ativo;
const senha = (this.editUserForm.get('senha')?.value || '').toString();
const confirmar = (this.editUserForm.get('confirmarSenha')?.value || '').toString();
if (senha || confirmar) {
if (!senha || !confirmar) {
this.editUserErrors = [{ message: 'Para alterar a senha, preencha senha e confirmacao.' }];
return;
}
if (senha.length < 6) {
this.editUserErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }];
return;
}
if (senha !== confirmar) {
this.editUserErrors = [{ message: 'As senhas nao conferem.' }];
return;
}
payload.senha = senha;
payload.confirmarSenha = confirmar;
}
if (Object.keys(payload).length === 0) {
this.editUserErrors = [{ message: 'Nenhuma alteracao detectada.' }];
return;
}
this.editUserSubmitting = true;
this.setEditFormDisabled(true);
this.editUserErrors = [];
this.editUserSuccess = '';
this.usersService.update(this.editUserTarget.id, payload).subscribe({
next: (updated) => {
this.editUserSubmitting = false;
this.setEditFormDisabled(false);
this.editUserSuccess = `Usuario ${updated.nome} atualizado.`;
this.editUserTarget = updated;
this.fetchManageUsers(this.managePage);
},
error: (err: HttpErrorResponse) => {
this.editUserSubmitting = false;
this.setEditFormDisabled(false);
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.editUserErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao atualizar usuario.',
}));
} else {
this.editUserErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }];
}
},
});
}
confirmDeleteUser(user: any) {
if (!confirm(`Excluir usuario ${user.nome}?`)) return;
this.usersService.update(user.id, { ativo: false }).subscribe({
next: () => this.fetchManageUsers(this.managePage),
});
}
hasFieldError(field: string): boolean {
return this.getFieldErrors(field).length > 0;
}
@ -318,13 +531,35 @@ export class Header {
this.createUserForbidden = false;
this.createUserSuccess = '';
this.createUserSubmitting = false;
this.setCreateFormDisabled(false);
this.createUserForm.reset({ permissao: '' });
}
private resetManageUsersState() {
this.manageUsersErrors = [];
this.manageUsersSuccess = '';
this.manageUsersLoading = false;
this.manageUsers = [];
this.manageSearch = '';
this.managePage = 1;
this.manageTotal = 0;
this.cancelEditUser();
}
private normalizeField(field?: string | null): string {
return (field || '').trim().toLowerCase();
}
private setCreateFormDisabled(disabled: boolean) {
if (disabled) this.createUserForm.disable({ emitEvent: false });
else this.createUserForm.enable({ emitEvent: false });
}
private setEditFormDisabled(disabled: boolean) {
if (disabled) this.editUserForm.disable({ emitEvent: false });
else this.editUserForm.enable({ emitEvent: false });
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const senha = group.get('senha')?.value;
const confirmar = group.get('confirmarSenha')?.value;
@ -332,5 +567,3 @@ export class Header {
return senha === confirmar ? null : { passwordsMismatch: true };
}
}

View File

@ -0,0 +1,485 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 12000;">
<div
class="toast border-0 shadow"
[class.show]="toastOpen"
[class.text-bg-success]="toastType === 'success'"
[class.text-bg-danger]="toastType === 'danger'"
>
<div class="toast-header border-bottom-0">
<strong class="me-auto">LineGestao</strong>
<button type="button" class="btn-close" (click)="toastOpen = false"></button>
</div>
<div class="toast-body bg-white rounded-bottom text-dark fw-bold">{{ toastMessage }}</div>
</div>
</div>
<section class="chips-page">
<span class="page-blob blob-1" aria-hidden="true"></span>
<span class="page-blob blob-2" aria-hidden="true"></span>
<span class="page-blob blob-3" aria-hidden="true"></span>
<span class="page-blob blob-4" aria-hidden="true"></span>
<div class="container-chips">
<div class="chips-card">
<!-- HEADER -->
<div class="chips-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-inboxes"></i> Gestão de Chips
</div>
<div class="header-title">
<h5 class="title mb-0">Chips Virgens e Recebidos</h5>
<small class="subtitle">Importação e acompanhamento</small>
</div>
<div class="header-actions"></div>
</div>
<div class="tab-row">
<button type="button" class="tab-btn" [class.active]="activeTab === 'chips'" (click)="setTab('chips')">
<i class="bi bi-sim"></i> Chips Virgens
</button>
<button type="button" class="tab-btn" [class.active]="activeTab === 'controle'" (click)="setTab('controle')">
<i class="bi bi-clipboard-data"></i> Controle Recebidos
</button>
</div>
<div class="filters-row" *ngIf="activeTab === 'controle'">
<div class="filter-item">
<app-select
class="select-glass"
size="sm"
[options]="anoOptions"
labelKey="label"
valueKey="value"
[(ngModel)]="controleAno"
(ngModelChange)="onControleAnoChange()"
></app-select>
</div>
<div class="filter-tabs">
<button type="button" class="filter-tab" [class.active]="controleResumo === ''" (click)="setControleResumo('')">Todos</button>
<button type="button" class="filter-tab" [class.active]="controleResumo === 'false'" (click)="setControleResumo('false')">Detalhado</button>
<button type="button" class="filter-tab" [class.active]="controleResumo === 'true'" (click)="setControleResumo('true')">Resumo</button>
</div>
</div>
<div class="controls">
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i
class="bi"
[class.bi-search]="!chipsLoading && !controleLoading"
[class.bi-hourglass-split]="chipsLoading || controleLoading"
[class.text-brand]="chipsLoading || controleLoading"
></i>
</span>
<input
*ngIf="activeTab === 'chips'"
class="form-control"
placeholder="Pesquisar Chips..."
[(ngModel)]="chipsSearch"
(ngModelChange)="onChipsSearch()"
/>
<input
*ngIf="activeTab === 'controle'"
class="form-control"
placeholder="Pesquisar Controle..."
[(ngModel)]="controleSearch"
(ngModelChange)="onControleSearch()"
/>
<button
class="btn btn-outline-secondary btn-clear"
type="button"
(click)="activeTab === 'chips' ? clearChipsSearch() : clearControleSearch()"
*ngIf="chipsSearch || controleSearch"
>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<app-select
*ngIf="activeTab === 'chips'"
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="chipsPageSize"
(ngModelChange)="onChipsPageSizeChange()"
[disabled]="chipsLoading"
></app-select>
<app-select
*ngIf="activeTab === 'controle'"
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="controlePageSize"
(ngModelChange)="onControlePageSizeChange()"
[disabled]="controleLoading"
></app-select>
</div>
</div>
</div>
</div>
<!-- BODY (scroll interno do card) -->
<div class="chips-body">
<!-- CHIPS -->
<ng-container *ngIf="activeTab === 'chips'">
<div class="content-scroll groups-container">
<div class="text-center p-5" *ngIf="chipsLoading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!chipsLoading && chipsGroups.length === 0">
Nenhum registro encontrado.
</div>
<div class="group-list" *ngIf="!chipsLoading">
<div
*ngFor="let g of pagedChipsGroups"
class="group-card"
[class.expanded]="expandedGroupObservacao === g.observacao"
>
<div class="group-header" (click)="toggleGroup(g.observacao)">
<div class="group-info">
<h6 class="group-title">{{ g.observacao }}</h6>
<div class="group-badges">
<span class="badge-pill">{{ g.total }} Registros</span>
</div>
</div>
<div class="group-toggle-icon">
<i class="bi bi-chevron-down"></i>
</div>
</div>
<div class="group-body-content" *ngIf="expandedGroupObservacao === g.observacao">
<div class="table-wrap inner-table-wrap">
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>NÚMERO DO CHIP</th>
<th>OBSERVAÇÕES</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of g.items">
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="font-monospace text-brand">{{ display(r.numeroDoChip) }}</td>
<td class="text-start td-clip" [title]="display(r.observacoes)">{{ display(r.observacoes) }}</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon info" (click)="openChipDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="table-pagination" *ngIf="!chipsLoading && chipsGroups.length > 0">
<div class="page-info">
Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="activePage === 1">
<button class="page-link" (click)="goToPage(activePage - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of activePageNumbers" [class.active]="p === activePage">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="activePage === activeTotalPages">
<button class="page-link" (click)="goToPage(activePage + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</ng-container>
<!-- CONTROLE -->
<ng-container *ngIf="activeTab === 'controle'">
<div class="content-scroll">
<div class="text-center p-5" *ngIf="controleLoading">
<span class="spinner-border text-brand"></span>
</div>
<div class="empty-group" *ngIf="!controleLoading && controleGroups.length === 0">
Nenhum registro encontrado.
</div>
<div class="group-list" *ngIf="!controleLoading">
<div
*ngFor="let g of pagedControleGroups"
class="group-card"
[class.expanded]="expandedControleConteudo === g.conteudo"
>
<div class="group-header" (click)="toggleControleGroup(g.conteudo)">
<div class="group-info">
<h6 class="group-title">{{ g.conteudo }}</h6>
<div class="group-badges">
<span class="badge-pill">{{ g.total }} Registros</span>
</div>
</div>
<div class="group-toggle-icon">
<i class="bi bi-chevron-down"></i>
</div>
</div>
<div class="group-body-content" *ngIf="expandedControleConteudo === g.conteudo">
<div class="table-wrap inner-table-wrap">
<ng-container *ngIf="getResumoItems(g.items) as resumoItems">
<div class="table-section" *ngIf="resumoItems.length > 0">
<div class="section-label">Resumo</div>
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ANO</th>
<th>NOTA FISCAL</th>
<th>DATA DA NF</th>
<th>QTD.</th>
<th>CONTEÚDO DA NF</th>
<th>DATA DO RECEBIMENTO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of resumoItems">
<td class="text-muted fw-bold">{{ display(r.ano) }}</td>
<td>{{ display(r.notaFiscal) }}</td>
<td>{{ formatDate(r.dataDaNf) }}</td>
<td class="fw-bold">{{ display(r.quantidade) }}</td>
<td class="text-start td-clip" [title]="display(r.conteudoDaNf)">{{ display(r.conteudoDaNf) }}</td>
<td>{{ formatDate(r.dataDoRecebimento) }}</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
<ng-container *ngIf="getDetalheItems(g.items) as detalheItems">
<div class="table-section" *ngIf="detalheItems.length > 0">
<div class="section-label">Detalhe</div>
<table class="table table-modern align-middle text-center mb-0">
<thead>
<tr>
<th>ANO</th>
<th>NOTA FISCAL</th>
<th>CHIP</th>
<th>SERIAL</th>
<th>NÚMERO DA LINHA</th>
<th>VALOR UNIT.</th>
<th>VALOR DA NF</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of detalheItems">
<td class="text-muted fw-bold">{{ display(r.ano) }}</td>
<td>{{ display(r.notaFiscal) }}</td>
<td class="font-monospace">{{ display(r.chip) }}</td>
<td class="font-monospace">{{ display(r.serial) }}</td>
<td class="font-monospace">{{ display(r.numeroDaLinha) }}</td>
<td class="text-end fw-bold">{{ formatMoney(r.valorUnit) }}</td>
<td class="text-end fw-bold">{{ formatMoney(r.valorDaNf) }}</td>
<td>
<div class="action-group justify-content-center">
<button class="btn-icon info" (click)="openControleDetail(r); $event.stopPropagation()" title="Detalhes">
<i class="bi bi-eye"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
</div>
</div>
</div>
</div>
<div class="table-pagination" *ngIf="!controleLoading && controleGroups.length > 0">
<div class="page-info">
Mostrando {{ activePageStart }} a {{ activePageEnd }} de {{ activeTotal }} grupos
</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="activePage === 1">
<button class="page-link" (click)="goToPage(activePage - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of activePageNumbers" [class.active]="p === activePage">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="activePage === activeTotalPages">
<button class="page-link" (click)="goToPage(activePage + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</ng-container>
</div>
</div>
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="chipDetailOpen || controleDetailOpen" (click)="closeChipDetail(); closeControleDetail()"></div>
<!-- MODAL CHIP -->
<div class="modal-custom" *ngIf="chipDetailOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-sim"></i></span>
Detalhes do Chip
</div>
<button class="btn btn-sm btn-icon" (click)="closeChipDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="chipDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!chipDetailLoading && chipDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações do Chip</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(chipDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Número do Chip</span>
<span class="val text-brand font-monospace">{{ display(chipDetailData.numeroDoChip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Observações</span>
<span class="val">{{ display(chipDetailData.observacoes) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL CONTROLE -->
<div class="modal-custom" *ngIf="controleDetailOpen">
<div class="modal-card modal-xl-custom" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg primary-soft"><i class="bi bi-clipboard-data"></i></span>
Detalhes do Recebimento
</div>
<button class="btn btn-sm btn-icon" (click)="closeControleDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="p-5 text-center text-muted" *ngIf="controleDetailLoading">
<span class="spinner-border me-2"></span> Carregando detalhes...
</div>
<div class="details-dashboard" *ngIf="!controleDetailLoading && controleDetailData">
<div class="detail-box w-100">
<div class="box-header justify-content-center">
<span><i class="bi bi-card-text me-2"></i> Informações da NF</span>
</div>
<div class="box-body">
<div class="info-grid">
<div class="info-item">
<span class="lbl">Ano</span>
<span class="val">{{ display(controleDetailData.ano) }}</span>
</div>
<div class="info-item">
<span class="lbl">Item</span>
<span class="val">{{ display(controleDetailData.item) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Nota Fiscal</span>
<span class="val">{{ display(controleDetailData.notaFiscal) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Chip</span>
<span class="val font-monospace">{{ display(controleDetailData.chip) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Serial</span>
<span class="val font-monospace">{{ display(controleDetailData.serial) }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Conteúdo da NF</span>
<span class="val">{{ display(controleDetailData.conteudoDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Número da Linha</span>
<span class="val font-monospace">{{ display(controleDetailData.numeroDaLinha) }}</span>
</div>
<div class="info-item">
<span class="lbl">Quantidade</span>
<span class="val">{{ display(controleDetailData.quantidade) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor Unit</span>
<span class="val">{{ formatMoney(controleDetailData.valorUnit) }}</span>
</div>
<div class="info-item">
<span class="lbl">Valor da NF</span>
<span class="val text-brand">{{ formatMoney(controleDetailData.valorDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Data da NF</span>
<span class="val">{{ formatDate(controleDetailData.dataDaNf) }}</span>
</div>
<div class="info-item">
<span class="lbl">Recebimento</span>
<span class="val">{{ formatDate(controleDetailData.dataDoRecebimento) }}</span>
</div>
<div class="info-item">
<span class="lbl">Tipo</span>
<span class="val">{{ isResumo(controleDetailData) ? "RESUMO" : "DETALHE" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,440 @@
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 { CustomSelectComponent } from '../../components/custom-select/custom-select';
// Interface para o Agrupamento
interface ChipGroup {
observacao: string;
total: number;
items: ChipVirgemListDto[];
}
interface ControleGroup {
conteudo: string;
total: number;
items: ControleRecebidoListDto[];
}
type ChipsSortKey = 'item' | 'numeroDoChip' | 'observacoes';
type ControleSortKey =
| 'ano' | 'item' | 'notaFiscal' | 'chip' | 'serial' | 'conteudoDaNf' | 'numeroDaLinha'
| 'valorUnit' | 'valorDaNf' | 'dataDaNf' | 'dataDoRecebimento' | 'quantidade' | 'isResumo';
@Component({
selector: 'app-chips-controle-recebidos',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './chips-controle-recebidos.html',
styleUrls: ['./chips-controle-recebidos.scss']
})
export class ChipsControleRecebidos implements OnInit, OnDestroy {
activeTab: 'chips' | 'controle' = 'chips';
// --- Chips ---
chipsRows: ChipVirgemListDto[] = [];
chipsGroups: ChipGroup[] = [];
pagedChipsGroups: ChipGroup[] = [];
expandedGroupObservacao: string | null = null;
chipsLoading = false;
chipsSearch = '';
chipsPage = 1;
chipsPageSize = 10;
chipsTotal = 0;
chipsSortBy: ChipsSortKey = 'item';
chipsSortDir: SortDir = 'asc';
private chipsSearchTimer: any = null;
// --- Controle ---
controleRows: ControleRecebidoListDto[] = [];
controleGroups: ControleGroup[] = [];
pagedControleGroups: ControleGroup[] = [];
expandedControleConteudo: string | null = null;
controleLoading = false;
controleSearch = '';
controlePage = 1;
controlePageSize = 10;
controleTotal = 0;
controleSortBy: ControleSortKey = 'ano';
controleSortDir: SortDir = 'desc';
controleAno: number | '' = '';
controleResumo: '' | 'true' | 'false' = '';
private controleSearchTimer: any = null;
// --- Opções ---
pageSizeOptions = [10, 20, 50, 100];
anoOptions = [
{ label: 'Todos', value: '' },
{ label: '2022', value: 2022 },
{ label: '2023', value: 2023 },
{ label: '2024', value: 2024 },
{ label: '2025', value: 2025 }
];
toastOpen = false;
toastMessage = '';
toastType: 'success' | 'danger' = 'success';
private toastTimer: any = null;
chipDetailOpen = false;
chipDetailLoading = false;
chipDetailData: ChipVirgemListDto | null = null;
controleDetailOpen = false;
controleDetailLoading = false;
controleDetailData: ControleRecebidoListDto | null = null;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private service: ChipsControleService,
private http: HttpClient
) {}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) return;
this.fetchChips();
this.fetchControle();
}
ngOnDestroy(): void {
if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer);
if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
}
setTab(tab: 'chips' | 'controle') {
this.activeTab = tab;
if (tab === 'chips') {
this.expandedGroupObservacao = null;
this.applyChipsPagination();
this.closeControleDetail();
} else {
this.expandedControleConteudo = null;
this.applyControlePagination();
this.closeChipDetail();
}
}
// =====================
// Chips Virgens
// =====================
fetchChips() {
this.chipsLoading = true;
this.service.getChipsVirgens({
search: this.chipsSearch,
page: 1,
pageSize: 5000,
sortBy: this.chipsSortBy,
sortDir: this.chipsSortDir
}).subscribe({
next: (res) => {
const items = (res as any)?.items ?? [];
this.chipsRows = items.map((x: any, idx: number) => this.normalizeChip(x, idx));
this.buildChipsGroups();
this.chipsTotal = this.chipsGroups.length;
this.applyChipsPagination();
this.chipsLoading = false;
},
error: () => {
this.chipsLoading = false;
this.showToast('Erro ao carregar Chips Virgens.', 'danger');
}
});
}
private buildChipsGroups() {
const groupsMap = new Map<string, ChipVirgemListDto[]>();
this.chipsRows.forEach(row => {
const key = row.observacoes && row.observacoes.trim() !== ''
? row.observacoes.trim()
: '(Sem Observações)';
if (!groupsMap.has(key)) groupsMap.set(key, []);
groupsMap.get(key)?.push(row);
});
this.chipsGroups = [];
groupsMap.forEach((items, key) => {
this.chipsGroups.push({ observacao: key, total: items.length, items });
});
this.chipsGroups.sort((a, b) => a.observacao.localeCompare(b.observacao));
this.expandedGroupObservacao = null;
}
private applyChipsPagination() {
const start = (this.chipsPage - 1) * this.chipsPageSize;
const end = start + this.chipsPageSize;
this.pagedChipsGroups = this.chipsGroups.slice(start, end);
if (this.expandedGroupObservacao && !this.pagedChipsGroups.some(g => g.observacao === this.expandedGroupObservacao)) {
this.expandedGroupObservacao = null;
}
}
toggleGroup(obs: string) {
this.expandedGroupObservacao = this.expandedGroupObservacao === obs ? null : obs;
}
openChipDetail(row: ChipVirgemListDto) {
if (!row?.id) return;
this.chipDetailOpen = true;
this.chipDetailLoading = true;
this.chipDetailData = null;
this.service.getChipVirgemById(row.id).subscribe({
next: (data) => {
this.chipDetailData = data ?? row;
this.chipDetailLoading = false;
},
error: () => {
this.chipDetailLoading = false;
this.chipDetailData = row;
}
});
}
closeChipDetail() {
this.chipDetailOpen = false;
this.chipDetailLoading = false;
this.chipDetailData = null;
}
onChipsSearch() {
if (this.chipsSearchTimer) clearTimeout(this.chipsSearchTimer);
this.chipsSearchTimer = setTimeout(() => {
this.chipsPage = 1;
this.fetchChips();
}, 300);
}
clearChipsSearch() {
this.chipsSearch = '';
this.chipsPage = 1;
this.fetchChips();
}
onChipsPageSizeChange() {
this.chipsPage = 1;
this.applyChipsPagination();
}
// =====================
// Controle Recebidos
// =====================
fetchControle() {
this.controleLoading = true;
this.service.getControleRecebidos({
search: this.controleSearch,
page: 1,
pageSize: 5000,
sortBy: this.controleSortBy,
sortDir: this.controleSortDir,
ano: this.controleAno,
isResumo: this.controleResumo
}).subscribe({
next: (res) => {
const items = (res as any)?.items ?? [];
this.controleRows = items.map((x: any, idx: number) => this.normalizeControle(x, idx));
this.buildControleGroups();
this.controleTotal = this.controleGroups.length;
this.applyControlePagination();
this.controleLoading = false;
},
error: () => {
this.controleLoading = false;
this.showToast('Erro ao carregar Controle.', 'danger');
}
});
}
onControleSearch() {
if (this.controleSearchTimer) clearTimeout(this.controleSearchTimer);
this.controleSearchTimer = setTimeout(() => {
this.controlePage = 1;
this.fetchControle();
}, 300);
}
clearControleSearch() {
this.controleSearch = '';
this.controlePage = 1;
this.fetchControle();
}
setControleSort(key: ControleSortKey) {
if (this.controleSortBy === key) {
this.controleSortDir = this.controleSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.controleSortBy = key;
this.controleSortDir = 'asc';
}
this.controlePage = 1;
this.fetchControle();
}
onControlePageSizeChange() {
this.controlePage = 1;
this.applyControlePagination();
}
onControleAnoChange() {
this.controlePage = 1;
this.fetchControle();
}
setControleResumo(val: '' | 'true' | 'false') {
this.controleResumo = val;
this.controlePage = 1;
this.fetchControle();
}
private buildControleGroups() {
const groupsMap = new Map<string, ControleRecebidoListDto[]>();
this.controleRows.forEach(row => {
const key = row.conteudoDaNf && row.conteudoDaNf.trim() !== ''
? row.conteudoDaNf.trim()
: '(Sem Conteúdo)';
if (!groupsMap.has(key)) groupsMap.set(key, []);
groupsMap.get(key)?.push(row);
});
this.controleGroups = [];
groupsMap.forEach((items, key) => {
this.controleGroups.push({ conteudo: key, total: items.length, items });
});
this.controleGroups.sort((a, b) => a.conteudo.localeCompare(b.conteudo));
this.expandedControleConteudo = null;
}
private applyControlePagination() {
const start = (this.controlePage - 1) * this.controlePageSize;
const end = start + this.controlePageSize;
this.pagedControleGroups = this.controleGroups.slice(start, end);
if (this.expandedControleConteudo && !this.pagedControleGroups.some(g => g.conteudo === this.expandedControleConteudo)) {
this.expandedControleConteudo = null;
}
}
toggleControleGroup(conteudo: string) {
this.expandedControleConteudo = this.expandedControleConteudo === conteudo ? null : conteudo;
}
openControleDetail(row: ControleRecebidoListDto) {
if (!row?.id) return;
this.controleDetailOpen = true;
this.controleDetailLoading = true;
this.controleDetailData = null;
this.service.getControleRecebidoById(row.id).subscribe({
next: (data) => {
this.controleDetailData = data ?? row;
this.controleDetailLoading = false;
},
error: () => {
this.controleDetailLoading = false;
this.controleDetailData = row;
}
});
}
closeControleDetail() {
this.controleDetailOpen = false;
this.controleDetailLoading = false;
this.controleDetailData = null;
}
// =====================
// Paginação e Helpers
// =====================
get activePage() { return this.activeTab === 'chips' ? this.chipsPage : this.controlePage; }
get activeTotal() { return this.activeTab === 'chips' ? this.chipsTotal : this.controleTotal; }
get activePageSize() { return this.activeTab === 'chips' ? this.chipsPageSize : this.controlePageSize; }
get activeTotalPages() { return Math.max(1, Math.ceil((this.activeTotal || 0) / (this.activePageSize || 10))); }
get activePageStart() { return this.activeTotal === 0 ? 0 : (this.activePage - 1) * this.activePageSize + 1; }
get activePageEnd() { return this.activeTotal === 0 ? 0 : Math.min(this.activePage * this.activePageSize, this.activeTotal); }
get activeLoading() { return this.activeTab === 'chips' ? this.chipsLoading : this.controleLoading; } // ✅ novo
get activePageNumbers() {
const total = this.activeTotalPages;
const current = this.activePage;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
goToPage(p: number) {
const target = Math.max(1, Math.min(this.activeTotalPages, p));
if (this.activeTab === 'chips') {
this.chipsPage = target;
this.applyChipsPagination();
} else {
this.controlePage = target;
this.applyControlePagination();
}
}
normalizeChip(x: any, idx: number): ChipVirgemListDto {
return {
id: String(x.id || idx),
item: Number(x.item || 0),
numeroDoChip: x.numeroDoChip || x.NumeroDoChip,
observacoes: x.observacoes || x.Observacoes
};
}
normalizeControle(x: any, idx: number): ControleRecebidoListDto {
return {
id: String(x.id || idx),
ano: x.ano,
item: x.item,
notaFiscal: x.notaFiscal,
chip: x.chip,
serial: x.serial,
conteudoDaNf: x.conteudoDaNf,
numeroDaLinha: x.numeroDaLinha,
valorUnit: x.valorUnit,
valorDaNf: x.valorDaNf,
dataDaNf: x.dataDaNf,
dataDoRecebimento: x.dataDoRecebimento,
quantidade: x.quantidade,
isResumo: x.isResumo
};
}
display(val: any) { return val ? String(val) : '-'; }
formatMoney(val: any) {
if (!val) return '-';
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val);
}
formatDate(val: any) { if (!val) return '-'; return new Date(val).toLocaleDateString('pt-BR'); }
isResumo(r: any) { return !!r.isResumo; }
getResumoItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => this.isResumo(r)); }
getDetalheItems(items: ControleRecebidoListDto[]) { return (items || []).filter(r => !this.isResumo(r)); }
trackById(idx: number, item: any) { return item.id; }
showToast(msg: string, type: 'success' | 'danger') {
this.toastMessage = msg;
this.toastType = type;
this.toastOpen = true;
setTimeout(() => this.toastOpen = false, 3000);
}
}

View File

@ -76,12 +76,8 @@
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">Itens por pág:</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -207,3 +203,4 @@
</div>
</div>
</div>

View File

@ -1,7 +1,8 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import {
DadosUsuariosService,
@ -16,10 +17,9 @@ type ViewMode = 'lines' | 'groups';
@Component({
selector: 'app-dados-usuarios',
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './dados-usuarios.html',
styleUrls: ['./dados-usuarios.scss'],
providers: [DadosUsuariosService]
styleUrls: ['./dados-usuarios.scss']
})
export class DadosUsuarios implements OnInit {
@ -34,6 +34,7 @@ export class DadosUsuarios implements OnInit {
// Paginação
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// Ordenação

View File

@ -116,7 +116,7 @@
<!-- KPIs -->
<div class="fat-kpis mt-4 animate-fade-in">
<div class="kpi kpi-stack kpi-stack-tight">
<span class="lbl">Total Clientes</span>
<span class="lbl">Clientes Faturados</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalClientes || 0 }}</span>
@ -124,7 +124,7 @@
</div>
<div class="kpi kpi-stack kpi-stack-tight">
<span class="lbl">Total Linhas</span>
<span class="lbl">Linhas Faturadas</span>
<span class="val">
<span *ngIf="loadingKpis" class="spinner-border spinner-border-sm text-muted"></span>
<span *ngIf="!loadingKpis">{{ kpiTotalLinhas || 0 }}</span>
@ -183,13 +183,8 @@
</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -452,3 +447,5 @@
</div>
</div>

View File

@ -12,6 +12,7 @@ import {
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import {
BillingService,
@ -33,7 +34,7 @@ interface BillingClientGroup {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent],
templateUrl: './faturamento.html',
styleUrls: ['./faturamento.scss']
})
@ -68,6 +69,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
// pagina por CLIENTES (grupos)
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0; // total de grupos
// agrupamento
@ -489,15 +491,35 @@ export class Faturamento implements AfterViewInit, OnDestroy {
let totalVivo = 0;
let totalLine = 0;
let totalLucro = 0;
const clientTotals = new Map<string, { vivo: number; line: number; lucro: number }>();
for (const r of arr) {
const c = (r.cliente ?? '').trim();
if (c) unique.add(c);
totalLinhas += Number(r.qtdLinhas ?? 0) || 0;
totalVivo += Number(r.valorContratoVivo ?? 0) || 0;
totalLine += Number(r.valorContratoLine ?? 0) || 0;
totalLucro += Number((r as any).lucro ?? 0) || 0;
const key = this.normalizeText(c);
if (!key) continue;
const vivo = Number(r.valorContratoVivo ?? 0) || 0;
const line = Number(r.valorContratoLine ?? 0) || 0;
const lucro = Number((r as any).lucro ?? 0) || 0;
const existing = clientTotals.get(key);
if (!existing) {
clientTotals.set(key, { vivo, line, lucro });
} else {
if (!existing.vivo && vivo) existing.vivo = vivo;
if (!existing.line && line) existing.line = line;
if (!existing.lucro && lucro) existing.lucro = lucro;
}
}
for (const vals of clientTotals.values()) {
totalVivo += vals.vivo;
totalLine += vals.line;
totalLucro += vals.lucro;
}
this.kpiTotalClientes = unique.size;

View File

@ -182,13 +182,7 @@
</span>
<div class="select-wrapper">
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="onPageSizeChange()" [disabled]="loading">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -498,10 +492,7 @@
<div class="form-field">
<label>Conta <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.conta">
<option value="" disabled>Selecione...</option>
<option *ngFor="let acc of contaOptions" [value]="acc">{{ acc }}</option>
</select>
<app-select class="form-select" size="sm" [options]="contaOptions" [(ngModel)]="createModel.conta"></app-select>
</div>
<div class="form-field">
@ -560,10 +551,7 @@
<div class="form-grid">
<div class="form-field span-2">
<label>Plano Contrato <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.planoContrato">
<option value="" disabled>Selecione...</option>
<option *ngFor="let p of planOptions" [value]="p">{{ p }}</option>
</select>
<app-select class="form-select" size="sm" [options]="planOptions" [(ngModel)]="createModel.planoContrato"></app-select>
</div>
<div class="form-field">
@ -578,10 +566,7 @@
<div class="form-field span-2">
<label>Status <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.status">
<option value="" disabled>Selecione...</option>
<option *ngFor="let s of statusOptions" [value]="s">{{ s }}</option>
</select>
<app-select class="form-select" size="sm" [options]="statusOptions" [(ngModel)]="createModel.status"></app-select>
</div>
</div>
</div>
@ -897,7 +882,7 @@
<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>Conta</label><select class="form-select form-select-sm" [(ngModel)]="editModel.conta"><option *ngIf="editModel.conta && !contaOptions.includes(editModel.conta)" [value]="editModel.conta">{{ editModel.conta }}</option><option *ngFor="let acc of contaOptions" [value]="acc">{{ acc }}</option></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>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
@ -910,7 +895,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><select class="form-select form-select-sm" [(ngModel)]="editModel.planoContrato"><option *ngIf="editModel.planoContrato && !planOptions.includes(editModel.planoContrato)" [ngValue]="editModel.planoContrato">{{ editModel.planoContrato }}</option><option *ngFor="let p of planOptions" [ngValue]="p">{{ p }}</option></select></div>
<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"><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>
@ -921,11 +906,11 @@
<summary class="box-header"><span><i class="bi bi-activity me-2"></i> Status & Logística</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>Status</label><select class="form-select form-select-sm" [(ngModel)]="editModel.status"><option *ngIf="editModel.status && !statusOptions.includes(editModel.status)" [value]="editModel.status">{{ editModel.status }}</option><option *ngFor="let s of statusOptions" [value]="s">{{ s }}</option></select></div>
<div class="form-field"><label>Status</label><app-select class="form-select" size="sm" [options]="statusOptionsForEdit" [(ngModel)]="editModel.status"></app-select></div>
<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>Skil</label><select class="form-select form-select-sm" [(ngModel)]="editModel.skil"><option *ngIf="editModel.skil && !skilOptions.includes(editModel.skil)" [value]="editModel.skil">{{ editModel.skil }}</option><option *ngFor="let k of skilOptions" [value]="k">{{ k }}</option></select></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>
</div>
@ -978,3 +963,4 @@
</div>
</div>
</div>

View File

@ -207,6 +207,7 @@
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); } }
}
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
.modal-body .box-body { overflow: visible; }
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
@ -231,8 +232,8 @@
.details-2col { grid-template-columns: 1fr 1fr; @media (max-width: 900px) { grid-template-columns: 1fr; } }
/* Caixas de Detalhes e Accordions simples */
details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: fit-content; &:not([open]) { padding-bottom: 0; } }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%; display: flex; flex-direction: column; }
details.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: fit-content; &:not([open]) { padding-bottom: 0; } }
div.detail-box { background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: visible; height: 100%; display: flex; flex-direction: column; }
summary.box-header { padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center; cursor: pointer; list-style: none; user-select: none; i:not(.transition-icon) { color: var(--brand); margin-right: 8px; } &::-webkit-details-marker { display: none; } .transition-icon { margin-left: auto; transition: transform 0.3s ease; font-size: 1rem; color: var(--muted); } }
details[open] summary .transition-icon { transform: rotate(180deg); color: var(--brand); }

View File

@ -13,10 +13,10 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
HttpClient,
HttpClientModule,
HttpParams,
HttpErrorResponse
} from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
@ -53,6 +53,7 @@ interface ApiLineList {
interface ApiLineDetail {
id: string;
item: number;
qtdLinhas?: number | null;
conta?: string | null;
linha?: string | null;
chip?: string | null;
@ -99,7 +100,7 @@ interface ClientGroupDto {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './geral.html',
styleUrls: ['./geral.scss']
})
@ -145,6 +146,7 @@ export class Geral implements AfterViewInit, OnDestroy {
sortDir: SortDir = 'asc';
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
detailOpen = false;
@ -200,6 +202,22 @@ export class Geral implements AfterViewInit, OnDestroy {
'TIM'
];
get contaOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.conta, this.contaOptions);
}
get planOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.planoContrato, this.planOptions);
}
get statusOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.status, this.statusOptions);
}
get skilOptionsForEdit(): string[] {
return this.mergeOption(this.editModel?.skil, this.skilOptions);
}
createModel: any = {
cliente: '',
docType: 'PF',
@ -1132,6 +1150,7 @@ export class Geral implements AfterViewInit, OnDestroy {
lucro: this.toNullableNumber(this.createModel.lucro)
};
this.http.post<ApiLineDetail>(this.apiBase, payload).subscribe({
next: async () => {
this.createSaving = false;
@ -1270,4 +1289,10 @@ export class Geral implements AfterViewInit, OnDestroy {
const n = parseFloat(v.toString().replace(',', '.'));
return Number.isNaN(n) ? null : n;
}
private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim();
if (!v) return list;
return list.includes(v) ? list : [v, ...list];
}
}

View File

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

View File

@ -88,18 +88,7 @@
</span>
<div class="select-wrapper">
<select
class="form-select form-select-sm select-glass"
[(ngModel)]="pageSize"
(change)="onPageSizeChange()"
[disabled]="loading"
>
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<i class="bi bi-chevron-down select-icon"></i>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="onPageSizeChange()" [disabled]="loading"></app-select>
</div>
</div>
</div>
@ -270,14 +259,7 @@
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.selectedClient"
(change)="onEditClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="editModel.selectedClient" (ngModelChange)="onEditClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="editClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
@ -287,19 +269,7 @@
<!-- Linha Antiga (select da Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL)</label>
<select
class="form-control form-control-sm"
[(ngModel)]="editModel.mobileLineId"
(change)="onEditLineChange()"
[disabled]="!editModel.selectedClient || editLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsEdit" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<app-select class="form-control" size="sm" [options]="lineOptionsEdit" labelKey="label" valueKey="id" [(ngModel)]="editModel.mobileLineId" (ngModelChange)="onEditLineChange()" [disabled]="!editModel.selectedClient || editLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="editLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
@ -394,14 +364,7 @@
<!-- Cliente (select) -->
<div class="form-field span-2">
<label>Cliente (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.selectedClient"
(change)="onCreateClientChange()"
>
<option value="">Selecione...</option>
<option *ngFor="let c of clientOptions" [value]="c">{{ c }}</option>
</select>
<app-select class="form-control" size="sm" [options]="clientOptions" [(ngModel)]="createModel.selectedClient" (ngModelChange)="onCreateClientChange()" placeholder="Selecione..."></app-select>
<small class="text-muted fw-bold" *ngIf="createClientsLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando clientes...
@ -411,19 +374,7 @@
<!-- Linha Antiga (select Geral) -->
<div class="form-field span-2">
<label>Linha Antiga (GERAL) <span class="text-danger">*</span></label>
<select
class="form-control form-control-sm"
[(ngModel)]="createModel.mobileLineId"
(change)="onCreateLineChange()"
[disabled]="!createModel.selectedClient || createLinesLoading"
>
<option value="">Selecione a linha do cliente...</option>
<!-- ✅ ITEM • LINHA • USUÁRIO -->
<option *ngFor="let l of lineOptionsCreate" [value]="l.id">
{{ l.item }} • {{ l.linha || '-' }} • {{ l.usuario || 'SEM USUÁRIO' }}
</option>
</select>
<app-select class="form-control" size="sm" [options]="lineOptionsCreate" labelKey="label" valueKey="id" [(ngModel)]="createModel.mobileLineId" (ngModelChange)="onCreateLineChange()" [disabled]="!createModel.selectedClient || createLinesLoading" placeholder="Selecione a linha do cliente..."></app-select>
<small class="text-muted fw-bold" *ngIf="createLinesLoading">
<span class="spinner-border spinner-border-sm me-2"></span>Carregando linhas...
@ -579,3 +530,4 @@
</div>
</div>

View File

@ -9,8 +9,9 @@ import {
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
@ -50,6 +51,7 @@ interface LineOptionDto {
usuario: string | null;
cliente?: string | null;
skil?: string | null;
label?: string;
}
interface MuregDetailDto {
@ -73,7 +75,7 @@ interface MuregDetailDto {
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './mureg.html',
styleUrls: ['./mureg.scss']
})
@ -109,6 +111,7 @@ export class Mureg implements AfterViewInit {
private searchTimer: any = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// ====== OPTIONS (GERAL) ======
@ -411,7 +414,8 @@ export class Mureg implements AfterViewInit {
chip: x.chip ?? null,
usuario: x.usuario ?? null,
cliente: x.cliente ?? null,
skil: x.skil ?? null
skil: x.skil ?? null,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}))
.filter(x => !!String(x.linha ?? '').trim());

View File

@ -1,3 +1,5 @@
@use 'sass:color';
/* Variáveis */
$bg-page: #f9fafb;
$white: #ffffff;
@ -153,7 +155,7 @@ $border: #e5e7eb;
font-weight: 800; letter-spacing: 0.5px;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: darken($warning, 10%); }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
}
.item-time { font-size: 12px; color: $text-secondary; font-weight: 500; }

View File

@ -1,48 +1,176 @@
<section class="create-user-page">
<div class="page-shell">
<div class="grid-shell">
<div class="form-card">
<div class="form-header">
<h1>Novo Usuário LineGestão</h1>
<p>Preencha os dados para criar um novo usuário.</p>
</div>
<form class="user-form">
<div class="form-field">
<div class="form-alert error" *ngIf="createErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of createErrors">{{ err.message }}</li>
</ul>
</div>
<div class="form-alert success" *ngIf="createSuccess">{{ createSuccess }}</div>
<form class="user-form" [formGroup]="createForm" (ngSubmit)="submitCreate()">
<div class="form-field" [class.has-error]="hasCreateFieldError('nome') || (createForm.get('nome')?.touched && createForm.get('nome')?.invalid)">
<label for="nome">Nome</label>
<input id="nome" type="text" placeholder="Nome completo" />
<input id="nome" type="text" placeholder="Nome completo" formControlName="nome" />
<small class="field-error" *ngIf="createForm.get('nome')?.touched && createForm.get('nome')?.invalid">Nome obrigatório.</small>
</div>
<div class="form-field">
<div class="form-field" [class.has-error]="hasCreateFieldError('email') || (createForm.get('email')?.touched && createForm.get('email')?.invalid)">
<label for="email">Email</label>
<input id="email" type="email" placeholder="nome@empresa.com" />
<input id="email" type="email" placeholder="nome@empresa.com" formControlName="email" />
<small class="field-error" *ngIf="createForm.get('email')?.touched && createForm.get('email')?.invalid">Email inválido.</small>
</div>
<div class="form-field">
<div class="form-field" [class.has-error]="hasCreateFieldError('senha') || (createForm.get('senha')?.touched && createForm.get('senha')?.invalid)">
<label for="senha">Senha</label>
<input id="senha" type="password" placeholder="Defina uma senha segura" />
<input id="senha" type="password" placeholder="Defina uma senha segura" formControlName="senha" />
<small class="field-error" *ngIf="createForm.get('senha')?.touched && createForm.get('senha')?.invalid">Senha inválida.</small>
</div>
<div class="form-field">
<div class="form-field" [class.has-error]="hasCreateFieldError('confirmarSenha') || (createForm.get('confirmarSenha')?.touched && createForm.get('confirmarSenha')?.invalid) || (createPasswordMismatch && createForm.get('confirmarSenha')?.touched)">
<label for="confirmarSenha">Confirmar Senha</label>
<input id="confirmarSenha" type="password" placeholder="Repita a senha" />
<input id="confirmarSenha" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" />
<small class="field-error" *ngIf="createPasswordMismatch && createForm.get('confirmarSenha')?.touched">As senhas não conferem.</small>
</div>
<div class="form-field">
<div class="form-field" [class.has-error]="hasCreateFieldError('permissao') || (createForm.get('permissao')?.touched && createForm.get('permissao')?.invalid)">
<label for="permissoes">Permissões</label>
<select id="permissoes">
<option value="" selected>Selecione o nível</option>
<option value="admin">Administrador</option>
<option value="gestor">Gestor</option>
<option value="operador">Operador</option>
<option value="leitura">Leitura</option>
</select>
<app-select formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
<small class="field-error" *ngIf="createForm.get('permissao')?.touched && createForm.get('permissao')?.invalid">Selecione uma permissão.</small>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary">Cancelar</button>
<button type="submit" class="btn-primary">Salvar</button>
<button type="button" class="btn-secondary" (click)="createForm.reset({ permissao: '' })" [disabled]="createSubmitting">Cancelar</button>
<button type="submit" class="btn-primary" [disabled]="createSubmitting || createForm.invalid">
<span *ngIf="!createSubmitting">Salvar</span>
<span *ngIf="createSubmitting">Salvando...</span>
</button>
</div>
</form>
</div>
<div class="list-card">
<div class="list-header">
<div>
<h2>Usuários</h2>
<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()" />
<button type="button" class="btn-secondary" (click)="onSearch()">Buscar</button>
<button type="button" class="btn-ghost" (click)="clearSearch()">Limpar</button>
</div>
</div>
<div class="list-body">
<div class="loading" *ngIf="loading">Carregando...</div>
<table *ngIf="!loading && users.length" class="users-table">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Permissão</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let u of users">
<td>{{ u.nome }}</td>
<td>{{ u.email }}</td>
<td class="cap">{{ u.permissao }}</td>
<td>
<span class="status-pill" [class.off]="u.ativo === false">
{{ u.ativo === false ? 'Inativo' : 'Ativo' }}
</span>
</td>
<td>
<button type="button" class="btn-link" (click)="openEdit(u)">Editar</button>
</td>
</tr>
</tbody>
</table>
<div class="empty" *ngIf="!loading && !users.length">
Nenhum usuario encontrado.
</div>
</div>
<div class="list-footer">
<div class="page-info">
Página {{ page }} de {{ totalPages }}
</div>
<div class="pagination">
<button type="button" class="btn-ghost" (click)="goToPage(page - 1)" [disabled]="page <= 1">Anterior</button>
<button type="button" class="btn-ghost" (click)="goToPage(p)" *ngFor="let p of pageNumbers" [class.active]="p === page">{{ p }}</button>
<button type="button" class="btn-ghost" (click)="goToPage(page + 1)" [disabled]="page >= totalPages">Próxima</button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="modal-overlay" *ngIf="editOpen" (click)="closeEdit()"></div>
<div class="modal-card" *ngIf="editOpen" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Editar Usuário</h3>
<button type="button" class="btn-icon close-x" (click)="closeEdit()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-alert error" *ngIf="editErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of editErrors">{{ err.message }}</li>
</ul>
</div>
<div class="form-alert success" *ngIf="editSuccess">{{ editSuccess }}</div>
<form class="user-form" id="editUserForm" [formGroup]="editForm" (ngSubmit)="submitEdit()">
<div class="form-field">
<label for="editNome">Nome</label>
<input id="editNome" type="text" placeholder="Nome completo" formControlName="nome" />
</div>
<div class="form-field">
<label for="editEmail">Email</label>
<input id="editEmail" type="email" placeholder="nome@empresa.com" formControlName="email" />
</div>
<div class="form-field">
<label for="editSenha">Nova senha (opcional)</label>
<input id="editSenha" type="password" placeholder="Nova senha" formControlName="senha" />
</div>
<div class="form-field">
<label for="editConfirmarSenha">Confirmar senha</label>
<input id="editConfirmarSenha" type="password" placeholder="Confirme a nova senha" formControlName="confirmarSenha" />
</div>
<div class="form-field">
<label for="editPermissao">Permissões</label>
<app-select formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
</div>
<div class="form-field inline">
<label for="editAtivo">Status</label>
<div class="toggle">
<input id="editAtivo" type="checkbox" formControlName="ativo" />
<span>Ativo</span>
</div>
</div>
</form>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" (click)="closeEdit()" [disabled]="editSubmitting">Cancelar</button>
<button type="submit" form="editUserForm" class="btn-primary" [disabled]="editSubmitting">
<span *ngIf="!editSubmitting">Salvar</span>
<span *ngIf="editSubmitting">Salvando...</span>
</button>
</div>
</div>

View File

@ -12,6 +12,13 @@
place-items: center;
}
.grid-shell {
width: 100%;
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1fr);
}
.form-card {
width: min(720px, 100%);
background: #ffffff;
@ -74,6 +81,174 @@
}
}
.form-field.inline {
grid-template-columns: 1fr;
}
.form-alert {
border-radius: 12px;
padding: 12px 14px;
font-size: 13px;
margin-bottom: 12px;
}
.form-alert.error {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.form-alert.success {
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #047857;
}
.field-error {
color: #b91c1c;
font-size: 12px;
}
.list-card {
width: min(900px, 100%);
background: #ffffff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
padding: 22px 22px 18px;
}
.list-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
h2 {
margin: 0;
font-size: 18px;
color: #0f172a;
}
p {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
}
.list-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
input {
height: 38px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 13px;
min-width: 220px;
}
}
.list-body {
margin-top: 16px;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #edf0f6;
text-align: left;
}
th {
font-weight: 600;
color: #475569;
}
}
.status-pill {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-weight: 600;
font-size: 12px;
}
.status-pill.off {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.list-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: #64748b;
}
.pagination {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.btn-ghost {
height: 34px;
border-radius: 10px;
border: 1px solid #d7dbe6;
background: #fff;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.btn-ghost.active {
background: #2f6bff;
border-color: #2f6bff;
color: #ffffff;
}
.btn-link {
border: none;
background: transparent;
color: #2f6bff;
cursor: pointer;
font-weight: 600;
}
.loading,
.empty {
padding: 18px 0;
text-align: center;
color: #64748b;
}
.cap {
text-transform: capitalize;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
@ -104,16 +279,75 @@
color: #0f172a;
}
.btn-secondary,
.btn-ghost {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary:hover,
.btn-secondary:hover {
.btn-secondary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
z-index: 999;
}
.modal-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(520px, 92vw);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.2);
z-index: 1000;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 10px;
border-bottom: 1px solid #edf0f6;
}
.modal-body {
padding: 16px 18px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 18px 16px;
}
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
}
@media (max-width: 768px) {
.grid-shell {
gap: 18px;
}
.form-card {
padding: 22px 20px 20px;
}
.list-card {
padding: 20px 18px 16px;
}
.form-actions {
flex-direction: column;
align-items: stretch;
@ -124,3 +358,15 @@
width: 100%;
}
}
@media (min-width: 980px) {
.grid-shell {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: start;
}
.form-card,
.list-card {
width: 100%;
}
}

View File

@ -1,11 +1,309 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { UsersService, CreateUserPayload, UpdateUserPayload, UserDto, ApiFieldError } from '../../services/users.service';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
@Component({
selector: 'app-novo-usuario',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent],
templateUrl: './novo-usuario.html',
styleUrls: ['./novo-usuario.scss'],
})
export class NovoUsuario {}
export class NovoUsuario implements OnInit {
createForm: FormGroup;
editForm: FormGroup;
permissionOptions = [
{ value: 'admin', label: 'Administrador' },
{ value: 'gestor', label: 'Gestor' },
];
createSubmitting = false;
editSubmitting = false;
createErrors: ApiFieldError[] = [];
editErrors: ApiFieldError[] = [];
createSuccess = '';
editSuccess = '';
users: UserDto[] = [];
loading = false;
search = '';
page = 1;
pageSize = 10;
total = 0;
editOpen = false;
private editBase: UserDto | null = null;
constructor(private usersService: UsersService, private fb: FormBuilder) {
this.createForm = this.fb.group(
{
nome: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
senha: ['', [Validators.required, Validators.minLength(6)]],
confirmarSenha: ['', [Validators.required, Validators.minLength(6)]],
permissao: ['', [Validators.required]],
},
{ validators: this.passwordsMatchValidator }
);
this.editForm = this.fb.group(
{
nome: [''],
email: [''],
senha: [''],
confirmarSenha: [''],
permissao: [''],
ativo: [true],
}
);
}
ngOnInit(): void {
this.fetchUsers(1);
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
}
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;
}
fetchUsers(goToPage?: number) {
if (goToPage) this.page = goToPage;
this.loading = true;
this.usersService.list({
search: this.search?.trim() || undefined,
page: this.page,
pageSize: this.pageSize,
}).subscribe({
next: (res) => {
this.users = res.items || [];
this.total = res.total || 0;
this.loading = false;
},
error: () => {
this.users = [];
this.total = 0;
this.loading = false;
}
});
}
onSearch() {
this.page = 1;
this.fetchUsers();
}
clearSearch() {
this.search = '';
this.page = 1;
this.fetchUsers();
}
onPageSizeChange() {
this.page = 1;
this.fetchUsers();
}
goToPage(p: number) {
this.page = p;
this.fetchUsers();
}
submitCreate() {
if (this.createSubmitting) return;
if (this.createForm.invalid) {
this.createForm.markAllAsTouched();
return;
}
this.createSubmitting = true;
this.setCreateFormDisabled(true);
this.createErrors = [];
this.createSuccess = '';
const payload = this.createForm.value as CreateUserPayload;
this.usersService.create(payload).subscribe({
next: (created) => {
this.createSubmitting = false;
this.setCreateFormDisabled(false);
this.createSuccess = `Usuario ${created.nome} criado com sucesso.`;
this.createForm.reset({ permissao: '' });
this.fetchUsers(1);
},
error: (err: HttpErrorResponse) => {
this.createSubmitting = false;
this.setCreateFormDisabled(false);
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.createErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao criar usuario.',
}));
} else {
this.createErrors = [{ message: err?.error?.message || 'Erro ao criar usuario.' }];
}
},
});
}
openEdit(user: UserDto) {
this.editOpen = true;
this.editErrors = [];
this.editSuccess = '';
this.editSubmitting = false;
this.setEditFormDisabled(false);
this.editBase = null;
this.editForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
this.usersService.getById(user.id).subscribe({
next: (full) => {
this.editBase = full;
this.editForm.reset({
nome: full.nome ?? '',
email: full.email ?? '',
senha: '',
confirmarSenha: '',
permissao: full.permissao ?? '',
ativo: full.ativo ?? true,
});
},
error: () => {
this.editErrors = [{ message: 'Erro ao carregar usuario.' }];
},
});
}
closeEdit() {
this.editOpen = false;
this.editErrors = [];
this.editSuccess = '';
this.editSubmitting = false;
this.editBase = null;
this.setEditFormDisabled(false);
}
submitEdit() {
if (this.editSubmitting || !this.editBase) return;
this.editErrors = [];
this.editSuccess = '';
const payload: UpdateUserPayload = {};
const nome = (this.editForm.get('nome')?.value || '').toString().trim();
const email = (this.editForm.get('email')?.value || '').toString().trim();
const permissao = (this.editForm.get('permissao')?.value || '').toString().trim();
const ativo = !!this.editForm.get('ativo')?.value;
if (nome && nome !== (this.editBase.nome || '').trim()) payload.nome = nome;
if (email && email !== (this.editBase.email || '').trim()) payload.email = email;
if (permissao && permissao !== (this.editBase.permissao || '').trim()) payload.permissao = permissao as any;
if ((this.editBase.ativo ?? true) !== ativo) payload.ativo = ativo;
const senha = (this.editForm.get('senha')?.value || '').toString();
const confirmar = (this.editForm.get('confirmarSenha')?.value || '').toString();
if (senha || confirmar) {
if (!senha || !confirmar) {
this.editErrors = [{ message: 'Para alterar a senha, preencha senha e confirmaçao.' }];
return;
}
if (senha.length < 6) {
this.editErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }];
return;
}
if (senha !== confirmar) {
this.editErrors = [{ message: 'As senhas nao conferem.' }];
return;
}
payload.senha = senha;
payload.confirmarSenha = confirmar;
}
if (Object.keys(payload).length === 0) {
this.editErrors = [{ message: 'Nenhuma alteraçao detectada.' }];
return;
}
this.editSubmitting = true;
this.setEditFormDisabled(true);
this.usersService.update(this.editBase.id, payload).subscribe({
next: (updated) => {
this.editSubmitting = false;
this.setEditFormDisabled(false);
this.editSuccess = `Usuario ${updated.nome} atualizado.`;
this.fetchUsers();
},
error: (err: HttpErrorResponse) => {
this.editSubmitting = false;
this.setEditFormDisabled(false);
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.editErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao atualizar usuario.',
}));
} else {
this.editErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }];
}
}
});
}
hasCreateFieldError(field: string): boolean {
return this.getFieldErrors(this.createErrors, field).length > 0;
}
hasEditFieldError(field: string): boolean {
return this.getFieldErrors(this.editErrors, field).length > 0;
}
getFieldErrors(source: ApiFieldError[], field: string): string[] {
const key = this.normalizeField(field);
return source
.filter((e) => this.normalizeField(e.field) === key)
.map((e) => e.message || 'Erro');
}
get createPasswordMismatch(): boolean {
return !!this.createForm.errors?.['passwordsMismatch'];
}
private normalizeField(field?: string | null): string {
return (field || '').trim().toLowerCase();
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const senha = group.get('senha')?.value;
const confirmar = group.get('confirmarSenha')?.value;
if (!senha || !confirmar) return null;
return senha === confirmar ? null : { passwordsMismatch: true };
}
private setCreateFormDisabled(disabled: boolean) {
if (disabled) this.createForm.disable({ emitEvent: false });
else this.createForm.enable({ emitEvent: false });
}
private setEditFormDisabled(disabled: boolean) {
if (disabled) this.editForm.disable({ emitEvent: false });
else this.editForm.enable({ emitEvent: false });
}
}

View File

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

View File

@ -9,7 +9,8 @@ import {
} from '@angular/core';
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
@ -49,11 +50,12 @@ interface LineOptionDto {
cliente: string | null;
usuario: string | null;
skil: string | null;
label?: string;
}
@Component({
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './troca-numero.html',
styleUrls: ['./troca-numero.scss']
})
@ -92,6 +94,7 @@ export class TrocaNumero implements AfterViewInit {
private searchTimer: any = null;
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// ====== EDIT MODAL ======
@ -357,7 +360,10 @@ export class TrocaNumero implements AfterViewInit {
this.http.get<LineOptionDto[]>(`${this.linesApiBase}/by-client`, { params }).subscribe({
next: (res) => {
this.linesFromClient = (res ?? []);
this.linesFromClient = (res ?? []).map((x) => ({
...x,
label: `${x.item ?? ''}${x.linha ?? '-'}${x.usuario ?? 'SEM USUÁRIO'}`
}));
this.loadingLines = false;
this.cdr.detectChanges();
},

View File

@ -39,7 +39,7 @@
<span class="lbl text-danger">Total Vencidos</span>
<span class="val text-danger">{{ kpiTotalVencidos }}</span>
</div>
<div class="kpi">
<div class="kpi kpi-stack">
<span class="lbl text-brand">Valor Total</span>
<span class="val text-brand">{{ kpiValorTotal | currency:'BRL' }}</span>
</div>
@ -56,12 +56,7 @@
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="font-size: 0.75rem;">Itens por pág:</span>
<select class="form-select form-select-sm select-glass" [(ngModel)]="pageSize" (change)="fetch(1)" [disabled]="loading" style="width: 80px;">
<option [ngValue]="10">10</option>
<option [ngValue]="20">20</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<app-select class="select-glass" size="sm" [options]="pageSizeOptions" [(ngModel)]="pageSize" (ngModelChange)="fetch(1)" [disabled]="loading" style="width: 80px;"></app-select>
</div>
</div>
</div>
@ -217,3 +212,4 @@
</div>
</div>
</div>

View File

@ -96,6 +96,14 @@
.val { font-size: 1.25rem; 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 */

View File

@ -3,6 +3,7 @@ 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 { CustomSelectComponent } from '../../components/custom-select/custom-select';
type SortDir = 'asc' | 'desc';
type ToastType = 'success' | 'danger';
@ -11,7 +12,7 @@ type ViewMode = 'lines' | 'groups';
@Component({
selector: 'app-vigencia',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './vigencia.html',
styleUrls: ['./vigencia.scss'],
})
@ -27,6 +28,7 @@ export class VigenciaComponent implements OnInit {
// Paginação
page = 1;
pageSize = 10;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
// Ordenação

View File

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

View File

@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type UserPermission = 'admin' | 'gestor' | 'operador' | 'leitura';
export type UserPermission = 'admin' | 'gestor';
export type UserDto = {
id: string;
@ -40,8 +40,12 @@ export type UsersListParams = {
};
export type UpdateUserPayload = {
permissao: UserPermission;
ativo: boolean;
nome?: string;
email?: string;
senha?: string;
confirmarSenha?: string;
permissao?: UserPermission;
ativo?: boolean;
};
export type PagedResult<T> = {
@ -73,6 +77,10 @@ export class UsersService {
return this.http.get<PagedResult<UserDto>>(`${this.baseApi}/users`, { params: httpParams });
}
getById(id: string): Observable<UserDto> {
return this.http.get<UserDto>(`${this.baseApi}/users/${id}`);
}
update(id: string, payload: UpdateUserPayload): Observable<UserDto> {
return this.http.patch<UserDto>(`${this.baseApi}/users/${id}`, payload);
}

View File

@ -23,6 +23,51 @@ body {
-webkit-font-smoothing: antialiased;
/* Garante scroll da página em todo o app */
overflow-y: auto !important;
/* Global select styling to match LineGestao UI */
select,
select.form-select,
select.form-control {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
height: 42px;
border-radius: 10px;
border: 1.5px solid rgba(15, 23, 42, 0.12);
padding: 0 36px 0 12px;
font-size: 14px;
font-weight: 500;
color: var(--text-main);
background-color: #fff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 20 20'%3E%3Cpath fill='%2364748B' d='M5.25 7.5 10 12.25 14.75 7.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 14px 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
select:focus,
select.form-select:focus,
select.form-control:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-soft);
}
select:disabled,
select.form-select:disabled,
select.form-control:disabled {
background-color: #f1f5f9;
color: var(--text-muted);
cursor: not-allowed;
}
select.form-select-sm,
select.form-control-sm {
height: 36px;
font-size: 13px;
padding-right: 32px;
background-position: right 10px center;
}
}
/* Utilitário de animação suave */
@ -234,3 +279,4 @@ app-header .modal-card .btn-secondary:hover {
width: 100%;
}
}