feat: Cliente poder alterar e solicitar alteracoes

This commit is contained in:
Leon 2026-03-05 18:30:45 -03:00
parent 5d0dc3b367
commit 79d372d67b
10 changed files with 881 additions and 15 deletions

View File

@ -21,6 +21,7 @@ import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
import { Historico } from './pages/historico/historico'; import { Historico } from './pages/historico/historico';
import { Perfil } from './pages/perfil/perfil'; import { Perfil } from './pages/perfil/perfil';
import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user'; import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user';
import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
@ -38,6 +39,7 @@ export const routes: Routes = [
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' }, { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' },
{ path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' }, { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' },
{ path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' },
{ path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' },
{ {
path: 'system/fornecer-usuario', path: 'system/fornecer-usuario',

View File

@ -29,6 +29,7 @@ export class AppComponent {
// ✅ rotas internas (LOGADO) que devem esconder footer // ✅ rotas internas (LOGADO) que devem esconder footer
private readonly loggedPrefixes = [ private readonly loggedPrefixes = [
'/geral', '/geral',
'/solicitacoes-linhas',
'/mureg', '/mureg',
'/faturamento', '/faturamento',
'/dadosusuarios', '/dadosusuarios',

View File

@ -548,6 +548,9 @@
<a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="canViewAll" routerLink="/historico" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-clock-history"></i> <span>Histórico</span> <i class="bi bi-clock-history"></i> <span>Histórico</span>
</a> </a>
<a *ngIf="canViewAll" routerLink="/solicitacoes" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-envelope-paper"></i> <span>Solicitações</span>
</a>
<a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()"> <a *ngIf="canViewAll" routerLink="/dadosusuarios" routerLinkActive="active" class="side-item" (click)="closeMenu()">
<i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span> <i class="bi bi-people-fill"></i> <span>Dados PF/PJ</span>
</a> </a>

View File

@ -93,6 +93,7 @@ export class Header implements AfterViewInit, OnDestroy {
'/resumo', '/resumo',
'/parcelamentos', '/parcelamentos',
'/historico', '/historico',
'/solicitacoes',
'/perfil', '/perfil',
'/system', '/system',
]; ];

View File

@ -1975,12 +1975,24 @@
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="editSaving"> <button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="editSaving || requestSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar <i class="bi bi-x-lg me-1"></i> Cancelar
</button> </button>
<ng-container *ngIf="isClientRestricted; else adminEditActions">
<button type="button" class="btn btn-outline-danger btn-sm" (click)="requestLineBlock()" [disabled]="editSaving || requestSaving">
<span *ngIf="!requestSaving"><i class="bi bi-lock me-1"></i> Bloquear Linha</span>
<span *ngIf="requestSaving"><span class="spinner-border spinner-border-sm me-2"></span> Enviando...</span>
</button>
<button type="button" class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving || requestSaving">
<span *ngIf="!editSaving">Salvar</span>
<span *ngIf="editSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</ng-container>
<ng-template #adminEditActions>
<button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving"> <button class="btn btn-brand btn-sm" (click)="saveEdit()" [disabled]="!editModel || editSaving">
Salvar Salvar
</button> </button>
</ng-template>
</div> </div>
</div> </div>
@ -1994,22 +2006,35 @@
</summary> </summary>
<div class="box-body"> <div class="box-body">
<div class="form-grid"> <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 />
</div>
<div class="form-field"> <div class="form-field">
<label>Linha</label> <label>Linha</label>
<input class="form-control form-control-sm bg-light" [(ngModel)]="editModel.linha" disabled /> <input class="form-control form-control-sm bg-light" [(ngModel)]="editModel.linha" readonly />
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Usuário</label> <label>Cliente</label>
<input class="form-control form-control-sm bg-light" [(ngModel)]="editModel.cliente" readonly />
</div>
<div class="form-field">
<label>Usuário da Linha</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /> <input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" />
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Centro de Custos</label> <label>Centro de Custos</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.centroDeCustos" /> <input class="form-control form-control-sm" [(ngModel)]="editModel.centroDeCustos" />
</div> </div>
<div class="form-field">
<label>Setor</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.setorNome" />
</div>
<div class="form-field">
<label>Franquia Line (GB)</label>
<input
class="form-control form-control-sm"
type="number"
step="0.01"
[(ngModel)]="editModel.franquiaLineSolicitada"
[disabled]="editSaving || requestSaving" />
</div>
</div> </div>
</div> </div>
</details> </details>
@ -2062,6 +2087,7 @@
</div> </div>
</div> </div>
</details> </details>
</div> </div>
<div class="edit-sections" *ngIf="!isClientRestricted"> <div class="edit-sections" *ngIf="!isClientRestricted">

View File

@ -22,6 +22,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel
import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { TenantSyncService } from '../../services/tenant-sync.service'; import { TenantSyncService } from '../../services/tenant-sync.service';
import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
import { firstValueFrom, Subscription, filter } from 'rxjs'; import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
@ -289,7 +290,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private planAutoFill: PlanAutoFillService, private planAutoFill: PlanAutoFillService,
private authService: AuthService, private authService: AuthService,
private router: Router, private router: Router,
private tenantSyncService: TenantSyncService private tenantSyncService: TenantSyncService,
private solicitacoesLinhasService: SolicitacoesLinhasService
) {} ) {}
private readonly apiBase = (() => { private readonly apiBase = (() => {
@ -347,6 +349,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
financeOpen = false; financeOpen = false;
editOpen = false; editOpen = false;
editSaving = false; editSaving = false;
requestSaving = false;
createOpen = false; createOpen = false;
createSaving = false; createSaving = false;
@ -858,6 +861,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.aparelhoReciboFile = null; this.aparelhoReciboFile = null;
this.editSaving = false; this.editSaving = false;
this.requestSaving = false;
this.createSaving = false; this.createSaving = false;
this.editModel = null; this.editModel = null;
@ -1865,6 +1869,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
async onEditar(r: LineRow) { async onEditar(r: LineRow) {
this.editOpen = true; this.editOpen = true;
this.editSaving = false; this.editSaving = false;
this.requestSaving = false;
this.editModel = null; this.editModel = null;
this.aparelhoNotaFiscalFile = null; this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null; this.aparelhoReciboFile = null;
@ -1942,11 +1947,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
} }
async saveEdit() { async saveEdit() {
if (!this.editingId || !this.editModel) return; if (!this.editingId || !this.editModel || this.requestSaving) return;
this.editSaving = true; this.editSaving = true;
const editingId = this.editingId; const editingId = this.editingId;
const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile); const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile);
const franquiaLineAtual = this.toNullableNumber(this.editModel.franquiaLine);
const franquiaLineSolicitada = this.toNullableNumber(this.editModel.franquiaLineSolicitada);
const shouldRequestFranquia =
this.isClientRestricted && !this.nullableNumberEquals(franquiaLineAtual, franquiaLineSolicitada);
let payload: UpdateMobileLineRequest; let payload: UpdateMobileLineRequest;
if (this.isClientRestricted) { if (this.isClientRestricted) {
@ -1954,15 +1963,21 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
item: this.toInt(this.editModel.item), item: this.toInt(this.editModel.item),
usuario: (this.editModel.usuario ?? '').toString(), usuario: (this.editModel.usuario ?? '').toString(),
centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(), centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(),
setorNome: (this.editModel.setorNome ?? '').toString(),
aparelhoId: (this.editModel.aparelhoId ?? null) as string | null, aparelhoId: (this.editModel.aparelhoId ?? null) as string | null,
aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(), aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(),
aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(), aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(),
aparelhoImei: (this.editModel.aparelhoImei ?? '').toString() aparelhoImei: (this.editModel.aparelhoImei ?? '').toString(),
franquiaLine: franquiaLineAtual
}; };
} else { } else {
this.calculateFinancials(this.editModel); this.calculateFinancials(this.editModel);
const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel; const {
contaEmpresa: _contaEmpresa,
franquiaLineSolicitada: _franquiaLineSolicitada,
...editModelPayload
} = this.editModel;
payload = { payload = {
...editModelPayload, ...editModelPayload,
@ -2004,12 +2019,48 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
if (shouldRequestFranquia) {
try {
await firstValueFrom(this.solicitacoesLinhasService.create({
lineId: editingId,
tipoSolicitacao: 'alteracao-franquia',
franquiaLineNova: franquiaLineSolicitada
}));
} catch (err) {
this.editSaving = false;
this.closeAllModals();
const msg =
(err as HttpErrorResponse)?.error?.message
|| 'Registro atualizado, mas falhou ao enviar a solicitação de franquia.';
await this.showToast(msg);
if (this.isGroupMode && this.expandedGroup) {
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
this.loadGroups();
this.loadKpis();
} else {
this.refreshData();
}
return;
}
}
this.editSaving = false; this.editSaving = false;
// fecha e limpa overlay SEMPRE // fecha e limpa overlay SEMPRE
this.closeAllModals(); this.closeAllModals();
if (shouldRequestFranquia) {
await this.showToast(
shouldUploadAttachments
? 'Registro e anexos atualizados! Solicitação de franquia enviada.'
: 'Registro atualizado! Solicitação de franquia enviada.'
);
} else {
await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!'); await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!');
}
if (this.isGroupMode && this.expandedGroup) { if (this.isGroupMode && this.expandedGroup) {
const term = (this.searchTerm ?? '').trim(); const term = (this.searchTerm ?? '').trim();
@ -2029,6 +2080,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
async requestLineBlock() {
if (!this.editingId || this.requestSaving) return;
this.requestSaving = true;
try {
await firstValueFrom(this.solicitacoesLinhasService.create({
lineId: this.editingId,
tipoSolicitacao: 'bloqueio'
}));
this.requestSaving = false;
this.closeAllModals();
await this.showToast('Solicitação de bloqueio enviada.');
} catch (err) {
this.requestSaving = false;
const msg = (err as HttpErrorResponse)?.error?.message || 'Erro ao enviar solicitação de bloqueio.';
await this.showToast(msg);
}
}
onAparelhoNotaFiscalSelected(event: Event) { onAparelhoNotaFiscalSelected(event: Event) {
const input = event.target as HTMLInputElement | null; const input = event.target as HTMLInputElement | null;
this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null; this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null;
@ -3618,6 +3689,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
valorContratoVivo: d.valorContratoVivo ?? null, valorContratoVivo: d.valorContratoVivo ?? null,
franquiaLine: d.franquiaLine ?? null, franquiaLine: d.franquiaLine ?? null,
franquiaLineSolicitada: d.franquiaLine ?? null,
franquiaGestao: d.franquiaGestao ?? null, franquiaGestao: d.franquiaGestao ?? null,
locacaoAp: d.locacaoAp ?? null, locacaoAp: d.locacaoAp ?? null,
valorContratoLine: d.valorContratoLine ?? null, valorContratoLine: d.valorContratoLine ?? null,
@ -3660,6 +3732,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return Number.isNaN(n) ? null : n; return Number.isNaN(n) ? null : n;
} }
private nullableNumberEquals(a: number | null, b: number | null): boolean {
if (a === null || b === null) return a === b;
return Math.abs(a - b) < 0.000001;
}
private mergeOption(current: any, list: string[]): string[] { private mergeOption(current: any, list: string[]): string[] {
const v = (current ?? '').toString().trim(); const v = (current ?? '').toString().trim();
if (!v) return list; if (!v) return list;

View File

@ -0,0 +1,138 @@
<section class="solicitacoes-page">
<div class="container-geral-responsive">
<div class="geral-card">
<div class="geral-header">
<div class="header-row-top">
<div class="title-badge">
<i class="bi bi-envelope-paper"></i> Administração
</div>
<div class="header-title">
<h5 class="title mb-0">Solicitações</h5>
<small class="subtitle">Pedidos de alteração de franquia e bloqueio enviados pelos usuários.</small>
</div>
<div class="header-actions d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-brand btn-sm" (click)="refresh()" [disabled]="loading">
<i class="bi bi-arrow-clockwise me-1"></i> Atualizar
</button>
</div>
</div>
<div class="controls mt-3">
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
<i class="bi" [class.bi-search]="!loading" [class.bi-hourglass-split]="loading"></i>
</span>
<input
class="form-control"
placeholder="Pesquisar por linha, usuário da linha ou descrição..."
[(ngModel)]="search"
(ngModelChange)="onSearchChange()" />
<button class="btn btn-outline-secondary btn-clear" type="button" (click)="clearSearch()" *ngIf="search">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="page-size d-flex align-items-center gap-2">
<span class="text-muted small fw-bold text-uppercase" style="letter-spacing: 0.5px; font-size: 0.75rem;">
Itens por pág:
</span>
<div class="select-wrapper">
<app-select
class="select-glass"
size="sm"
[options]="pageSizeOptions"
[(ngModel)]="pageSize"
(ngModelChange)="onPageSizeChange()"
[disabled]="loading"></app-select>
</div>
</div>
<div class="table-summary" *ngIf="!loading && !errorMsg">
Mostrando <strong>{{ pageStart }}</strong>-<strong>{{ pageEnd }}</strong> de <strong>{{ total }}</strong>
</div>
</div>
</div>
<div class="geral-body">
<div class="table-wrap">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
<div class="alert alert-danger m-4" *ngIf="!loading && errorMsg">
{{ errorMsg }}
</div>
<div class="empty-group" *ngIf="!loading && !errorMsg && items.length === 0">
Nenhuma solicitação encontrada.
</div>
<table class="table table-modern align-middle mb-0" *ngIf="!loading && !errorMsg && items.length > 0">
<colgroup>
<col class="col-date" />
<col class="col-cliente" />
<col class="col-linha" />
<col class="col-usuario" />
<col class="col-tipo" />
<col class="col-franquia" />
<col class="col-franquia" />
<col class="col-descricao" />
</colgroup>
<thead>
<tr>
<th>DATA</th>
<th>CLIENTE</th>
<th>LINHA</th>
<th>USUARIO LINHA</th>
<th>TIPO</th>
<th>FRANQUIA ANTES</th>
<th>FRANQUIA DEPOIS</th>
<th>DESCRICAO</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of items; trackBy: trackBySolicitacao">
<td>
<div class="date-cell">
<span class="date-main">{{ formatDate(item.createdAt) }}</span>
<span class="date-sub">{{ formatTime(item.createdAt) }}</span>
</div>
</td>
<td class="cell-ellipsis" [title]="item.tenantNome || '-'">{{ item.tenantNome || '-' }}</td>
<td class="mono-cell">{{ item.linha || '-' }}</td>
<td class="cell-ellipsis" [title]="item.usuarioLinha || '-'">{{ item.usuarioLinha || '-' }}</td>
<td>
<span [class]="tipoBadgeClass(item.tipoSolicitacao)">{{ tipoLabel(item.tipoSolicitacao) }}</span>
</td>
<td class="franquia-cell">{{ formatFranquia(item.franquiaLineAtual) }}</td>
<td class="franquia-cell">{{ formatFranquia(item.franquiaLineNova) }}</td>
<td class="message-cell" [title]="descricao(item)">
<span class="message-text">{{ descricao(item) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="geral-footer">
<div class="small text-muted fw-bold">Mostrando {{ pageStart }}{{ pageEnd }} de {{ total }} registros</div>
<nav>
<ul class="pagination pagination-sm mb-0 pagination-modern">
<li class="page-item" [class.disabled]="page === 1 || loading">
<button class="page-link" (click)="goToPage(page - 1)">Anterior</button>
</li>
<li class="page-item" *ngFor="let p of pageNumbers" [class.active]="p === page">
<button class="page-link" (click)="goToPage(p)">{{ p }}</button>
</li>
<li class="page-item" [class.disabled]="page === totalPages || loading">
<button class="page-link" (click)="goToPage(page + 1)">Próxima</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,368 @@
:host {
--brand: #e33dcf;
--text: #111214;
--card-bg: rgba(255, 255, 255, 0.88);
--card-border: 1px solid rgba(227, 61, 207, 0.14);
--stroke: rgba(16, 24, 40, 0.08);
--soft: rgba(17, 18, 20, 0.64);
display: block;
color: var(--text);
}
.solicitacoes-page {
min-height: 100vh;
padding: 0 0 18px;
background:
radial-gradient(920px 420px at 15% 8%, rgba(227, 61, 207, 0.1), transparent 60%),
radial-gradient(860px 420px at 85% 20%, rgba(3, 15, 170, 0.07), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f6fb 70%);
}
.container-geral-responsive {
width: calc(100vw - 2px);
max-width: none;
margin: 16px auto 24px;
position: relative;
z-index: 1;
}
.geral-card {
border-radius: 22px;
overflow: hidden;
background: var(--card-bg);
border: var(--card-border);
backdrop-filter: blur(8px);
box-shadow: 0 18px 40px rgba(17, 18, 20, 0.1);
display: flex;
flex-direction: column;
min-height: 64vh;
}
.geral-header {
padding: 16px 22px 14px;
border-bottom: 1px solid var(--stroke);
background: linear-gradient(180deg, rgba(227, 61, 207, 0.05), rgba(255, 255, 255, 0.16));
}
.header-row-top {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: center;
}
.title-badge {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(227, 61, 207, 0.2);
background: rgba(255, 255, 255, 0.8);
font-weight: 800;
font-size: 12px;
}
.header-title {
justify-self: center;
text-align: center;
}
.title {
font-size: 24px;
font-weight: 900;
letter-spacing: -0.01em;
}
.subtitle {
color: var(--soft);
font-weight: 600;
}
.header-actions {
justify-self: end;
}
.btn-brand {
background: var(--brand);
border-color: var(--brand);
color: #fff;
font-weight: 800;
}
.controls {
display: grid;
grid-template-columns: minmax(320px, 1.3fr) auto auto;
align-items: center;
gap: 14px;
}
.search-group {
max-width: 700px;
width: 100%;
border-radius: 13px;
border: 1px solid rgba(16, 24, 40, 0.16);
overflow: hidden;
box-shadow: 0 4px 10px rgba(16, 24, 40, 0.05);
background: #fff;
.input-group-text,
.form-control,
.btn-clear {
border: none;
}
.input-group-text {
color: rgba(17, 18, 20, 0.55);
}
.form-control {
font-size: 13px;
}
}
.geral-body {
flex: 1;
padding: 0 4px 10px;
overflow: hidden;
}
.table-wrap {
width: 100%;
min-width: 0;
max-width: 100%;
min-height: clamp(260px, 44vh, 500px);
max-height: 60vh;
border: 1px solid rgba(16, 24, 40, 0.1);
border-radius: 14px;
overflow-x: hidden;
overflow-y: auto;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.96) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.table-modern {
width: 100%;
min-width: 0;
table-layout: fixed;
}
.table-modern col.col-date { width: 9%; }
.table-modern col.col-cliente { width: 14%; }
.table-modern col.col-linha { width: 10%; }
.table-modern col.col-usuario { width: 15%; }
.table-modern col.col-tipo { width: 14%; }
.table-modern col.col-franquia { width: 12%; }
.table-modern col.col-descricao { width: 14%; }
.table-modern thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f6f8fc;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(17, 18, 20, 0.74);
padding: 10px 12px;
border-bottom: 1px solid #e4e9f3;
white-space: normal;
word-break: break-word;
line-height: 1.2;
}
.table-modern thead th:nth-child(6),
.table-modern thead th:nth-child(7),
.table-modern tbody td:nth-child(6),
.table-modern tbody td:nth-child(7) {
min-width: 0;
text-align: center;
}
.table-modern tbody td {
font-size: 13px;
padding: 11px 12px;
border-bottom: 1px solid #ebeff6;
vertical-align: middle;
white-space: normal;
word-break: break-word;
color: #1a1c20;
}
.table-modern tbody tr:nth-child(even) {
background: rgba(243, 247, 253, 0.56);
}
.table-modern tbody tr:hover {
background: #eef5ff;
}
.date-cell {
display: flex;
flex-direction: column;
line-height: 1.15;
gap: 3px;
}
.date-main {
font-weight: 700;
color: #1a1c20;
}
.date-sub {
font-size: 11px;
color: rgba(17, 18, 20, 0.55);
}
.mono-cell {
font-family: "JetBrains Mono", "Consolas", "Monaco", monospace;
font-variant-numeric: tabular-nums;
font-size: 12px;
font-weight: 700;
white-space: normal;
word-break: break-all;
}
.cell-ellipsis {
overflow: visible;
text-overflow: unset;
white-space: normal;
}
.type-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 5px 10px;
font-size: 11px;
font-weight: 800;
line-height: 1;
border: 1px solid transparent;
white-space: nowrap;
}
.type-badge--franquia {
color: #1e4e8f;
background: rgba(44, 121, 232, 0.14);
border-color: rgba(44, 121, 232, 0.24);
}
.type-badge--bloqueio {
color: #8f2f2f;
background: rgba(221, 74, 74, 0.14);
border-color: rgba(221, 74, 74, 0.25);
}
.type-badge--default {
color: #5b6173;
background: rgba(118, 127, 154, 0.14);
border-color: rgba(118, 127, 154, 0.24);
}
.franquia-cell {
text-align: center;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.message-cell {
white-space: normal;
}
.message-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.32;
color: rgba(17, 18, 20, 0.8);
word-break: break-word;
}
.table-summary {
justify-self: end;
font-size: 12px;
color: rgba(17, 18, 20, 0.64);
font-weight: 600;
strong {
color: #111214;
font-weight: 800;
}
}
.empty-group {
padding: 36px 18px;
text-align: center;
color: rgba(17, 18, 20, 0.64);
}
.geral-footer {
display: none;
}
.table-wrap::-webkit-scrollbar {
height: 10px;
width: 10px;
}
.table-wrap::-webkit-scrollbar-track {
background: rgba(17, 18, 20, 0.06);
}
.table-wrap::-webkit-scrollbar-thumb {
background: rgba(103, 114, 143, 0.45);
border-radius: 999px;
}
@media (max-width: 1200px) {
.controls {
grid-template-columns: 1fr auto;
}
.table-summary {
grid-column: 1 / -1;
justify-self: start;
}
.table-modern col.col-date { width: 10%; }
.table-modern col.col-cliente { width: 13%; }
.table-modern col.col-linha { width: 9%; }
.table-modern col.col-usuario { width: 14%; }
.table-modern col.col-tipo { width: 14%; }
.table-modern col.col-franquia { width: 13%; }
.table-modern col.col-descricao { width: 14%; }
.table-modern thead th,
.table-modern tbody td {
padding: 9px 8px;
font-size: 11px;
}
}
@media (max-width: 992px) {
.header-row-top {
grid-template-columns: 1fr;
text-align: center;
}
.title-badge,
.header-title,
.header-actions {
justify-self: center;
}
.controls {
grid-template-columns: 1fr;
align-items: start;
}
.message-text {
-webkit-line-clamp: 3;
}
}

View File

@ -0,0 +1,189 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { SolicitacaoLinhaDto, SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service';
@Component({
selector: 'app-solicitacoes-linhas',
standalone: true,
imports: [CommonModule, FormsModule, CustomSelectComponent],
templateUrl: './solicitacoes-linhas.html',
styleUrls: ['./solicitacoes-linhas.scss'],
})
export class SolicitacoesLinhas implements OnInit, OnDestroy {
items: SolicitacaoLinhaDto[] = [];
loading = false;
errorMsg = '';
page = 1;
pageSize = 20;
pageSizeOptions = [10, 20, 50, 100];
total = 0;
search = '';
private searchTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly solicitacoesService: SolicitacoesLinhasService) {}
ngOnInit(): void {
this.fetch(1);
}
ngOnDestroy(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
}
refresh(): void {
this.fetch();
}
onSearchChange(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
}
this.searchTimer = setTimeout(() => {
this.page = 1;
this.fetch();
}, 300);
}
clearSearch(): void {
this.search = '';
this.page = 1;
this.fetch();
}
onPageSizeChange(): void {
this.page = 1;
this.fetch();
}
goToPage(pageNumber: number): void {
this.page = Math.max(1, Math.min(this.totalPages, pageNumber));
this.fetch();
}
get totalPages(): number {
return Math.ceil((this.total || 0) / this.pageSize) || 1;
}
get pageNumbers(): number[] {
const total = this.totalPages;
const current = this.page;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
get pageStart(): number {
return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
}
get pageEnd(): number {
if (this.total === 0) return 0;
return Math.min(this.page * this.pageSize, this.total);
}
private parseDate(value?: string | null): Date | null {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
formatDateTime(value?: string | null): string {
const d = this.parseDate(value);
if (!d) return '-';
return d.toLocaleString('pt-BR');
}
formatDate(value?: string | null): string {
const d = this.parseDate(value);
if (!d) return '-';
return d.toLocaleDateString('pt-BR');
}
formatTime(value?: string | null): string {
const d = this.parseDate(value);
if (!d) return '--:--';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
formatFranquiaValor(value?: number | null): string {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
}
formatFranquia(value?: number | null): string {
const formatted = this.formatFranquiaValor(value);
return formatted === '-' ? '-' : `${formatted} GB`;
}
tipoLabel(value?: string | null): string {
const v = (value ?? '').toString().trim().toUpperCase();
if (v === 'ALTERACAO_FRANQUIA') return 'Alteração de franquia';
if (v === 'BLOQUEIO') return 'Bloqueio';
return v || '-';
}
tipoBadgeClass(value?: string | null): string {
const v = (value ?? '').toString().trim().toUpperCase();
if (v === 'ALTERACAO_FRANQUIA') return 'type-badge type-badge--franquia';
if (v === 'BLOQUEIO') return 'type-badge type-badge--bloqueio';
return 'type-badge type-badge--default';
}
trackBySolicitacao(_: number, item: SolicitacaoLinhaDto): string {
return item.id;
}
descricao(item: SolicitacaoLinhaDto): string {
const tipo = (item.tipoSolicitacao ?? '').toString().trim().toUpperCase();
const linha = (item.linha ?? '').toString().trim();
if (tipo === 'ALTERACAO_FRANQUIA') {
return `Mudanca de franquia de ${this.formatFranquiaValor(item.franquiaLineAtual)} para ${this.formatFranquiaValor(item.franquiaLineNova)}`;
}
if (tipo === 'BLOQUEIO') {
return `Bloqueio da linha ${linha || '-'}`;
}
return (item.mensagem ?? '').toString().trim() || '-';
}
private fetch(goToPage?: number): void {
if (goToPage) this.page = goToPage;
this.loading = true;
this.errorMsg = '';
this.solicitacoesService
.list({
page: this.page,
pageSize: this.pageSize,
search: this.search?.trim() || undefined,
})
.subscribe({
next: (res) => {
this.items = res.items || [];
this.total = res.total || 0;
this.page = res.page || this.page;
this.pageSize = res.pageSize || this.pageSize;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMsg = 'Não foi possível carregar as solicitações.';
},
});
}
}

View File

@ -0,0 +1,61 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface PagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
}
export interface SolicitacaoLinhaDto {
id: string;
tenantId: string;
tenantNome?: string | null;
mobileLineId?: string | null;
linha?: string | null;
usuarioLinha?: string | null;
tipoSolicitacao: string;
franquiaLineAtual?: number | null;
franquiaLineNova?: number | null;
solicitanteNome?: string | null;
mensagem: string;
status: string;
createdAt: string;
}
export interface SolicitacaoLinhaCreatePayload {
lineId: string;
tipoSolicitacao: 'alteracao-franquia' | 'bloqueio';
franquiaLineNova?: number | null;
}
@Injectable({ providedIn: 'root' })
export class SolicitacoesLinhasService {
private readonly baseUrl: string;
constructor(private readonly http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseUrl = `${apiBase}/solicitacoes-linhas`;
}
list(params?: { page?: number; pageSize?: number; search?: string }): Observable<PagedResult<SolicitacaoLinhaDto>> {
let httpParams = new HttpParams()
.set('page', String(params?.page ?? 1))
.set('pageSize', String(params?.pageSize ?? 20));
const search = (params?.search ?? '').trim();
if (search) {
httpParams = httpParams.set('search', search);
}
return this.http.get<PagedResult<SolicitacaoLinhaDto>>(this.baseUrl, { params: httpParams });
}
create(payload: SolicitacaoLinhaCreatePayload): Observable<SolicitacaoLinhaDto> {
return this.http.post<SolicitacaoLinhaDto>(this.baseUrl, payload);
}
}