Backup do LineGestão
This commit is contained in:
parent
4d357a4530
commit
a8e40b640d
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class AppComponent {
|
|||
'/trocanumero',
|
||||
'/dashboard', // ✅ ADICIONADO: esconde footer na página de dashboard
|
||||
'/notificacoes',
|
||||
'/chips-controle-recebidos',
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<div class="app-select" [class.open]="isOpen" [class.disabled]="disabled" [class.sm]="size === 'sm'">
|
||||
<button
|
||||
type="button"
|
||||
class="app-select-trigger"
|
||||
(click)="toggle()"
|
||||
[attr.aria-expanded]="isOpen"
|
||||
[attr.aria-disabled]="disabled"
|
||||
>
|
||||
<span class="app-select-label">{{ displayLabel }}</span>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
|
||||
<div class="app-select-panel" *ngIf="isOpen">
|
||||
<button
|
||||
type="button"
|
||||
class="app-select-option"
|
||||
*ngFor="let opt of options; trackBy: trackByValue"
|
||||
[class.selected]="isSelected(opt)"
|
||||
(click)="selectOption(opt)"
|
||||
>
|
||||
<span class="label">{{ getOptionLabel(opt) }}</span>
|
||||
<i class="bi bi-check2" *ngIf="isSelected(opt)"></i>
|
||||
</button>
|
||||
|
||||
<div class="app-select-empty" *ngIf="!options || options.length === 0">
|
||||
Nenhuma opção
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
.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;
|
||||
.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); }
|
||||
}
|
||||
|
||||
/* --- NOTIFICAÇÕES (Dropdown) --- */
|
||||
.notifications-menu { position: relative; }
|
||||
@media (max-width: 900px) { .nav-links { display: none; } }
|
||||
.logged-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.header-inner.container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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); } }
|
||||
|
||||
.user-trigger {
|
||||
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; }
|
||||
}
|
||||
.options-item {
|
||||
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 {
|
||||
position: relative;
|
||||
|
||||
&.has-unread {
|
||||
color: $primary;
|
||||
background: rgba(28, 56, 201, 0.06);
|
||||
}
|
||||
|
||||
&.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;
|
||||
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; } }
|
||||
|
||||
@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); }
|
||||
.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; }
|
||||
}
|
||||
|
||||
.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;
|
||||
/* 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(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;
|
||||
}
|
||||
|
||||
@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;
|
||||
/* Lado Esquerdo */
|
||||
.manage-left {
|
||||
display: flex; flex-direction: column;
|
||||
border-right: 1px solid $border-color;
|
||||
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; }
|
||||
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; } }
|
||||
}
|
||||
}
|
||||
|
||||
.notifications-body {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
.manage-table-wrap {
|
||||
flex: 1; overflow-y: auto; position: relative; overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.manage-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
.notifications-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
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;
|
||||
|
||||
.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;
|
||||
/* Classes de alinhamento no TD */
|
||||
&.text-center { text-align: center; overflow: visible; }
|
||||
&.text-end { text-align: right; overflow: visible; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
|
||||
&:hover { background: $bg-light; }
|
||||
&.danger { color: $danger; &:hover { background: rgba($danger, 0.05); } }
|
||||
}
|
||||
|
||||
.divider { height: 1px; background: $border-color; margin: 4px 0; }
|
||||
}
|
||||
|
||||
/* --- MODAL NOVO USUÁRIO --- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.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; } }
|
||||
|
||||
.form-alert {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
&.error {
|
||||
background: rgba($danger, 0.08);
|
||||
color: darken($danger, 5%);
|
||||
border: 1px solid rgba($danger, 0.25);
|
||||
}
|
||||
&.success {
|
||||
background: rgba(#22c55e, 0.1);
|
||||
color: #15803d;
|
||||
border: 1px solid rgba(#22c55e, 0.25);
|
||||
.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); }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0 20px 18px;
|
||||
.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; }
|
||||
}
|
||||
|
||||
.close-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
.modal-card .user-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
/* 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 */
|
||||
|
||||
.modal-card .form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
.placeholder-content {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.3s ease;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
}
|
||||
.placeholder-icon {
|
||||
font-size: 64px;
|
||||
color: $text-muted;
|
||||
opacity: 0.2; /* Estilo marca d'água */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
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);
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: 11px;
|
||||
color: $danger;
|
||||
.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; }
|
||||
}
|
||||
|
||||
.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) {
|
||||
@media (max-width: 1200px) {
|
||||
.modal-card.manage-users-modal {
|
||||
width: min(980px, 92vw);
|
||||
height: min(600px, 86vh);
|
||||
}
|
||||
.modal-card {
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
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 {
|
||||
padding: 12px 16px;
|
||||
h3 { font-size: 15px; }
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
/* Essencial para que o conteúdo não estoure o modal quando encolhido */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-card .btn-primary,
|
||||
.modal-card .btn-secondary {
|
||||
width: 100%;
|
||||
/* Compactar formulários */
|
||||
.form-field {
|
||||
margin-bottom: 12px;
|
||||
label { font-size: 12px; }
|
||||
input, select {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-alert {
|
||||
padding: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.refined-form {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -1,48 +1,176 @@
|
|||
<section class="create-user-page">
|
||||
<div class="page-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 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>
|
||||
|
||||
<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" formControlName="nome" />
|
||||
<small class="field-error" *ngIf="createForm.get('nome')?.touched && createForm.get('nome')?.invalid">Nome obrigatório.</small>
|
||||
</div>
|
||||
|
||||
<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" formControlName="email" />
|
||||
<small class="field-error" *ngIf="createForm.get('email')?.touched && createForm.get('email')?.invalid">Email inválido.</small>
|
||||
</div>
|
||||
|
||||
<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" formControlName="senha" />
|
||||
<small class="field-error" *ngIf="createForm.get('senha')?.touched && createForm.get('senha')?.invalid">Senha inválida.</small>
|
||||
</div>
|
||||
|
||||
<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" formControlName="confirmarSenha" />
|
||||
<small class="field-error" *ngIf="createPasswordMismatch && createForm.get('confirmarSenha')?.touched">As senhas não conferem.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field" [class.has-error]="hasCreateFieldError('permissao') || (createForm.get('permissao')?.touched && createForm.get('permissao')?.invalid)">
|
||||
<label for="permissoes">Permissões</label>
|
||||
<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" (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>
|
||||
|
||||
<form class="user-form">
|
||||
<div class="form-field">
|
||||
<label for="nome">Nome</label>
|
||||
<input id="nome" type="text" placeholder="Nome completo" />
|
||||
<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="form-field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" placeholder="nome@empresa.com" />
|
||||
<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="form-field">
|
||||
<label for="senha">Senha</label>
|
||||
<input id="senha" type="password" placeholder="Defina uma senha segura" />
|
||||
<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 class="form-field">
|
||||
<label for="confirmarSenha">Confirmar Senha</label>
|
||||
<input id="confirmarSenha" type="password" placeholder="Repita a senha" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary">Cancelar</button>
|
||||
<button type="submit" class="btn-primary">Salvar</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue