Feat: Adição Lote de Linhas

This commit is contained in:
Eduardo 2026-02-27 14:28:50 -03:00
parent ec3abc056f
commit 096306e852
20 changed files with 1536 additions and 281 deletions

View File

@ -57,3 +57,18 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
## Planilha Modelo (GERAL) - Lote de Linhas
- Local do botão:
- Página `Geral`
- Modal `Adicionar linha` ou `Novo cliente`
- Modo `Lote de Linhas`
- Bloco de importação por Excel
- Botão: `Baixar Modelo (GERAL)`
- Endpoint chamado pelo front-end:
- `GET /api/templates/planilha-geral`
- Arquivo baixado:
- `MODELO_GERAL_LINEGESTAO.xlsx`

12
package-lock.json generated
View File

@ -29,6 +29,7 @@
"@types/bootstrap": "^5.2.10",
"@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19",
"baseline-browser-mapping": "^2.10.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@ -3732,13 +3733,16 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/beasties": {

View File

@ -43,6 +43,7 @@
"@types/bootstrap": "^5.2.10",
"@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19",
"baseline-browser-mapping": "^2.10.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",

View File

@ -343,7 +343,10 @@ export class Header implements AfterViewInit, OnDestroy {
}
getVigenciaLabel(notification: NotificationDto): string {
return this.getNotificationTipo(notification) === 'Vencido' ? 'Venceu em' : 'Vence em';
const tipo = this.getNotificationTipo(notification);
if (tipo === 'Vencido') return 'Venceu em';
if (tipo === 'AVencer') return 'Vence em';
return 'Atualizado em';
}
getVigenciaDate(notification: NotificationDto): string {
@ -357,7 +360,11 @@ export class Header implements AfterViewInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR');
}
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
getNotificationTipo(notification: NotificationDto): string {
if (notification.tipo === 'RenovacaoAutomatica') {
return 'RenovacaoAutomatica';
}
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo;

View File

@ -24,6 +24,7 @@ import {
BillingUpdateRequest
} from '../../services/billing';
import { AuthService } from '../../services/auth.service';
import { LinesService } from '../../services/lines.service';
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
interface BillingClientGroup {
@ -51,6 +52,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private billing: BillingService,
private linesService: LinesService,
private cdr: ChangeDetectorRef,
private authService: AuthService
) {}
@ -106,6 +108,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
isAdmin = false;
private searchTimer: any = null;
private searchResolvedClients: string[] = [];
private searchResolveVersion = 0;
// cache do ALL
private allCache: BillingItem[] = [];
@ -351,22 +355,59 @@ export class Faturamento implements AfterViewInit, OnDestroy {
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.searchTimer = setTimeout(async () => {
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
await this.resolveSearchClientsByLineOrChip();
this.refreshData();
}, 250);
}
clearSearch() {
this.searchTerm = '';
this.searchResolvedClients = [];
this.page = 1;
this.expandedGroup = null;
this.groupRows = [];
this.refreshData();
}
private isSpecificLineOrChipSearch(term: string): boolean {
const digits = (term ?? '').replace(/\D/g, '');
return digits.length >= 8;
}
private async resolveSearchClientsByLineOrChip(): Promise<void> {
const term = (this.searchTerm ?? '').trim();
const requestVersion = ++this.searchResolveVersion;
if (!term || !this.isSpecificLineOrChipSearch(term)) {
this.searchResolvedClients = [];
return;
}
try {
const response = await new Promise<any>((resolve, reject) => {
this.linesService.getLines(1, 200, term).subscribe({
next: resolve,
error: reject
});
});
if (requestVersion !== this.searchResolveVersion) return;
const clients = (response?.items ?? [])
.map((x: any) => (x?.cliente ?? '').toString().trim())
.filter((x: string) => !!x);
this.searchResolvedClients = Array.from(new Set(clients));
} catch {
if (requestVersion !== this.searchResolveVersion) return;
this.searchResolvedClients = [];
}
}
// --------------------------
// Data
// --------------------------
@ -513,8 +554,12 @@ export class Faturamento implements AfterViewInit, OnDestroy {
}
const term = this.normalizeText(this.searchTerm);
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x)));
if (term) {
arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term));
arr = arr.filter((r) =>
this.buildGlobalSearchBlob(r).includes(term) ||
(resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente)))
);
}
// KPIs

View File

@ -42,6 +42,7 @@
</button>
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
<input #batchExcelInput type="file" class="d-none" accept=".xlsx" (change)="onBatchExcelSelected($event)" />
<button
type="button"
@ -302,9 +303,37 @@
<div class="group-body" *ngIf="expandedGroup === group.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Gerenciar Grupo</small>
<button class="btn btn-sm btn-add-line-group" (click)="onAddLineToGroup(group.cliente)">
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
</button>
<div class="d-flex align-items-center gap-2 flex-wrap justify-content-end">
<ng-container *ngIf="hasGroupLineSelectionTools">
<button class="btn btn-sm btn-glass" type="button" (click)="toggleSelectAllReservaGroupLines()">
<i class="bi bi-check2-square me-1"></i>
{{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }}
</button>
</ng-container>
<ng-container *ngIf="isReservaExpandedGroup">
<button
class="btn btn-sm btn-brand"
type="button"
(click)="openReservaTransferModal()"
[disabled]="reservaSelectedCount === 0"
>
<i class="bi bi-arrow-left-right me-1"></i> Atribuir Selecionadas ({{ reservaSelectedCount }})
</button>
</ng-container>
<ng-container *ngIf="canMoveSelectedLinesToReserva">
<button
class="btn btn-sm btn-send-reserva-group"
type="button"
(click)="openMoveToReservaModal()"
[disabled]="reservaSelectedCount === 0"
>
<i class="bi bi-box-arrow-left me-1"></i> Enviar p/ Reserva ({{ reservaSelectedCount }})
</button>
</ng-container>
<button class="btn btn-sm btn-add-line-group" (click)="onAddLineToGroup(group.cliente)">
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
</button>
</div>
</div>
<!-- ✅ wrapper com classe extra para permitir MAIS ALTURA em notebook/TV via SCSS -->
@ -316,25 +345,48 @@
<table class="table table-modern table-modern-responsive align-middle text-center mb-0" *ngIf="!loadingLines">
<thead>
<tr>
<th *ngIf="hasGroupLineSelectionTools" style="width: 52px;">
<input
class="line-select-checkbox"
type="checkbox"
[checked]="groupLines.length > 0 && reservaSelectedCount === groupLines.length"
(click)="$event.stopPropagation()"
(change)="toggleSelectAllReservaGroupLines()"
aria-label="Selecionar todas as linhas do grupo"
/>
</th>
<th>ITEM</th>
<th>LINHA</th>
<th>USUÁRIO</th>
<th>STATUS</th>
<th>VENCIMENTO</th>
<th>AÇÕES</th>
<th class="actions-col-main">AÇÕES</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of groupLines" class="table-row-item">
<td *ngIf="hasGroupLineSelectionTools">
<input
class="line-select-checkbox"
type="checkbox"
[checked]="isReservaLineSelected(r.id)"
(click)="$event.stopPropagation()"
(change)="toggleReservaLineSelection(r.id, $any($event.target).checked)"
[attr.aria-label]="'Selecionar linha ' + (r.linha || r.item)"
/>
</td>
<td class="text-muted fw-bold">{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha }}</td>
<td class="fw-black text-blue" [attr.title]="(r.chip || '') ? ('ICCID: ' + r.chip) : ''">
{{ r.linha }}
<div class="small text-muted fw-normal" *ngIf="r.chip">ICCID: {{ r.chip }}</div>
</td>
<td class="text-dark">{{ r.usuario || '-' }}</td>
<td>
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td class="text-muted small fw-bold">{{ r.contrato }}</td>
<td>
<td class="actions-col-main">
<div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
@ -425,7 +477,7 @@
</div>
</th>
<th class="text-center">AÇÕES</th>
<th class="text-center actions-col-main">AÇÕES</th>
</tr>
</thead>
@ -451,7 +503,7 @@
</td>
<td class="text-center fw-bold text-muted small">{{ r.skil }}</td>
<td class="text-center fw-bold text-muted small">{{ r.contrato }}</td>
<td class="text-center">
<td class="text-center actions-col-main">
<div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
@ -500,14 +552,14 @@
<!-- Backdrop -->
<div
class="modal-backdrop-custom"
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
(click)="closeAllModals()">
</div>
<!-- Overlay (captura clique fora) -->
<div
class="modal-custom"
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
(click)="closeAllModals()"
>
<!-- CREATE MODAL -->
@ -907,233 +959,122 @@
e podem ser diferentes entre linhas.
</div>
<div class="batch-mass-input-box">
<div class="batch-mass-input-box mb-3">
<div class="batch-mass-input-head">
<div>
<div class="batch-mass-title"><i class="bi bi-clipboard2-plus me-2"></i>Entrada em Massa</div>
<div class="batch-mass-title"><i class="bi bi-file-earmark-excel me-2"></i>Importar Planilha (Colunas da GERAL)</div>
<div class="batch-mass-sub">
Cole ou digite várias linhas em sequência. Formato padrão:
<code>linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao</code>
Use uma planilha Excel com os mesmos cabeçalhos da <strong>GERAL</strong> (não precisa ter uma aba chamada <strong>GERAL</strong>). A coluna <code>ITÉM</code> não é necessária; se vier preenchida, será ignorada e o sistema gera a sequência automaticamente.
</div>
</div>
<div class="batch-mass-controls">
<label class="small fw-bold text-muted mb-0">Separador</label>
<select class="form-select form-select-sm" [(ngModel)]="batchMassSeparatorMode" (ngModelChange)="onBatchMassInputChange()">
<option value="AUTO">Automático</option>
<option value="SEMICOLON">;</option>
<option value="TAB">TAB</option>
<option value="PIPE">|</option>
</select>
<div class="d-flex gap-2 align-items-center flex-wrap justify-content-end">
<button type="button" class="btn btn-sm btn-glass" (click)="onDownloadBatchExcelTemplate()" [disabled]="createSaving || batchExcelTemplateDownloading || batchExcelPreviewLoading">
<span *ngIf="!batchExcelTemplateDownloading"><i class="bi bi-download me-1"></i> Baixar Modelo (GERAL)</span>
<span *ngIf="batchExcelTemplateDownloading"><span class="spinner-border spinner-border-sm me-2"></span> Baixando...</span>
</button>
<button type="button" class="btn btn-sm btn-brand" (click)="onImportBatchExcel()" [disabled]="createSaving || batchExcelPreviewLoading">
<span *ngIf="!batchExcelPreviewLoading"><i class="bi bi-paperclip me-1"></i> Anexar Excel</span>
<span *ngIf="batchExcelPreviewLoading"><span class="spinner-border spinner-border-sm me-2"></span> Lendo...</span>
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="clearBatchExcelPreview()" [disabled]="createSaving || (!batchExcelPreview && !batchExcelPreviewLoading)">
<i class="bi bi-x-circle me-1"></i> Limpar Prévia
</button>
</div>
</div>
<details class="batch-mass-guide" open>
<summary>
<span><i class="bi bi-list-ol me-2"></i>Ordem Oficial de Colunas</span>
<small>Use essa ordem para reduzir erro de importação por texto</small>
</summary>
<div class="batch-mass-guide-body">
<div class="batch-mass-guide-list">
<div class="batch-mass-guide-item" *ngFor="let col of batchMassColumnGuide; let i = index">
<span class="pos">{{ i + 1 }}</span>
<span class="meta">
<span class="name">
{{ col.label }} <span class="text-danger" *ngIf="col.required">*</span>
</span>
<span class="hint">
{{ col.canUseDefault ? 'Aceita parâmetro padrão do lote' : 'Preenchimento por linha' }}
</span>
<span class="note" *ngIf="col.note">{{ col.note }}</span>
</span>
</div>
</div>
<div class="batch-mass-guide-note">
Regra: <strong>valor por linha</strong> sobrescreve <strong>parâmetro padrão do lote</strong>. Se um campo
obrigatório ficar vazio, a linha entra como inválida.
</div>
</div>
</details>
<details class="batch-mass-defaults">
<summary>
<span><i class="bi bi-sliders2 me-2"></i>Parâmetros Padrão do Lote (opcional)</span>
<small>Usados quando a coluna não vier na entrada em massa</small>
</summary>
<div class="batch-mass-defaults-body">
<div class="form-grid">
<div class="form-field">
<label>Empresa (Conta)</label>
<app-select
class="form-select"
size="sm"
[options]="contaEmpresaOptions"
[placeholder]="loadingAccountCompanies ? 'Carregando empresas...' : 'Selecione a empresa'"
[(ngModel)]="createModel.contaEmpresa"
(ngModelChange)="onContaEmpresaChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Conta</label>
<app-select
class="form-select"
size="sm"
[options]="contaOptionsForCreate"
[disabled]="!createModel.contaEmpresa"
[placeholder]="createModel.contaEmpresa ? 'Selecione a conta' : 'Selecione a empresa primeiro'"
[(ngModel)]="createModel.conta"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field span-2">
<label>Plano Contrato</label>
<app-select
class="form-select"
size="sm"
[options]="planOptions"
[(ngModel)]="createModel.planoContrato"
(ngModelChange)="onPlanoChange(false); onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Status</label>
<app-select
class="form-select"
size="sm"
[options]="statusOptions"
[(ngModel)]="createModel.status"
(ngModelChange)="onBatchMassInputChange()"
></app-select>
</div>
<div class="form-field">
<label>Usuário padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Tipo de Chip padrão</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.tipoDeChip" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field">
<label>Dt. Efetivação Serviço</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtEfetivacaoServico" (ngModelChange)="onBatchMassInputChange()" />
</div>
<div class="form-field span-2">
<label>Dt. Término Fidelização</label>
<input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dtTerminoFidelizacao" (ngModelChange)="onBatchMassInputChange()" />
</div>
</div>
</div>
</details>
<textarea
class="form-control batch-mass-textarea"
rows="5"
[(ngModel)]="batchMassInputText"
(ngModelChange)="onBatchMassInputChange()"
placeholder="Exemplo:
11999999999;8955000000000000001;João;eSIM;SMART EMPRESAS 6GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-01;2027-01-01
11999999998;8955000000000000002;Maria;Físico;SMART EMPRESAS 10GB;ATIVO;VIVO MACROPHONY;0430237019;2026-01-02;2027-01-02"
></textarea>
<div class="batch-mass-actions">
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassExample()" [disabled]="createSaving">
<i class="bi bi-stars me-1"></i> Usar Exemplo
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="useBatchMassHeaderTemplate()" [disabled]="createSaving">
<i class="bi bi-type me-1"></i> Usar Cabeçalho
</button>
<button type="button" class="btn btn-sm btn-glass" (click)="previewBatchMassInput()" [disabled]="createSaving">
<i class="bi bi-eye me-1"></i> Pré-visualizar
</button>
<button
type="button"
class="btn btn-sm btn-brand"
(click)="applyBatchMassInput('ADD')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-plus-circle me-1"></i> Adicionar ao Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass"
(click)="applyBatchMassInput('REPLACE')"
[disabled]="createSaving || !batchMassInputText.trim()"
>
<i class="bi bi-arrow-repeat me-1"></i> Substituir Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass text-danger"
(click)="clearBatchMassInput()"
[disabled]="createSaving || (!batchMassInputText && !batchMassHasPreview)"
>
<i class="bi bi-eraser me-1"></i> Limpar Campo
</button>
</div>
<div class="batch-mass-preview" *ngIf="batchMassHasPreview">
<div class="batch-mass-preview" *ngIf="batchExcelPreview as excelPreview">
<div class="batch-mass-preview-pills">
<span class="summary-pill total">Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }}</span>
<span class="summary-pill ok">Válidas: {{ batchMassPreview?.validRows || 0 }}</span>
<span class="summary-pill warn" *ngIf="(batchMassPreview?.invalidRows || 0) > 0">
Inválidas: {{ batchMassPreview?.invalidRows || 0 }}
</span>
<span class="summary-pill dup" *ngIf="(batchMassPreview?.duplicateRows || 0) > 0">
Duplicadas: {{ batchMassPreview?.duplicateRows || 0 }}
</span>
<span class="summary-pill" *ngIf="batchMassPreview">Separador: {{ batchMassSeparatorLabel }}</span>
<span class="summary-pill" *ngIf="batchMassPreview?.hasHeader">Com cabeçalho</span>
<span class="summary-pill total">Aba: {{ excelPreview.sheetName || 'GERAL' }}</span>
<span class="summary-pill total">Linhas lidas: {{ excelPreview.totalRows || 0 }}</span>
<span class="summary-pill ok">Válidas: {{ excelPreview.validRows || 0 }}</span>
<span class="summary-pill warn" *ngIf="(excelPreview.invalidRows || 0) > 0">Inválidas: {{ excelPreview.invalidRows || 0 }}</span>
<span class="summary-pill dup" *ngIf="(excelPreview.duplicateRows || 0) > 0">Duplicadas: {{ excelPreview.duplicateRows || 0 }}</span>
<span class="summary-pill" *ngIf="excelPreview.nextItemStart > 0">Próx. ITÉM (sistema): {{ excelPreview.nextItemStart }}</span>
</div>
<div class="batch-mass-preview-errors" *ngIf="(batchMassPreview?.parseErrors?.length || 0) > 0">
<div class="fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Erros de parsing</div>
<ul>
<li *ngFor="let err of batchMassPreview?.parseErrors">{{ err }}</li>
<div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerErrors?.length || 0) > 0">
<strong>Erros de cabeçalho/estrutura</strong>
<ul class="mb-0 mt-1">
<li *ngFor="let err of excelPreview.headerErrors">
<strong *ngIf="err.column">{{ err.column }}:</strong> {{ err.message }}
</li>
</ul>
</div>
<div class="batch-mass-preview-table-wrap" *ngIf="(batchMassPreview?.recognizedRows || 0) > 0">
<div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerWarnings?.length || 0) > 0">
<strong>Avisos</strong>
<ul class="mb-0 mt-1">
<li *ngFor="let warn of excelPreview.headerWarnings">
<strong *ngIf="warn.column">{{ warn.column }}:</strong> {{ warn.message }}
</li>
</ul>
</div>
<div class="batch-mass-actions mt-2">
<button
type="button"
class="btn btn-sm btn-brand"
(click)="applyBatchExcelPreview('ADD')"
[disabled]="createSaving || batchExcelPreviewLoading || !excelPreview.canProceed"
>
<i class="bi bi-plus-circle me-1"></i> Adicionar Linhas Válidas ao Lote
</button>
<button
type="button"
class="btn btn-sm btn-glass"
(click)="applyBatchExcelPreview('REPLACE')"
[disabled]="createSaving || batchExcelPreviewLoading || !excelPreview.canProceed"
>
<i class="bi bi-arrow-repeat me-1"></i> Substituir Lote com Linhas Válidas
</button>
</div>
<div class="batch-mass-preview-table-wrap mt-2" *ngIf="(excelPreview.rows.length || 0) > 0">
<table class="batch-mass-preview-table">
<thead>
<tr>
<th>Linha origem</th>
<th>Planilha</th>
<th>ITÉM (origem)</th>
<th>ITÉM (sistema)</th>
<th>Linha</th>
<th>Chip</th>
<th>Plano</th>
<th>Status</th>
<th>Conta</th>
<th>Status</th>
<th>Validação</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of batchMassPreviewRowsPreview">
<td>#{{ row.line }}</td>
<td>{{ row.data['linha'] || '-' }}</td>
<td>{{ row.data['chip'] || '-' }}</td>
<td>{{ row.data['planoContrato'] || '-' }}</td>
<td>{{ row.data['status'] || '-' }}</td>
<td>{{ row.data['conta'] || '-' }}</td>
<td>
<span class="batch-row-valid" *ngIf="row.errors.length === 0"><i class="bi bi-check-circle-fill"></i> OK</span>
<div class="batch-row-errors-compact" *ngIf="row.errors.length > 0" [attr.title]="row.errors.join(' | ')">
<div class="batch-row-error-main">{{ row.errors[0] }}</div>
<div class="batch-row-more" *ngIf="row.errors.length > 1">+{{ row.errors.length - 1 }} pendência(s)</div>
<tr *ngFor="let row of batchExcelPreviewRowsPreview">
<td>#{{ row.sourceRowNumber }}</td>
<td>{{ row.sourceItem ?? '-' }}</td>
<td>{{ row.generatedItemPreview ?? '-' }}</td>
<td>{{ row.data.linha || '-' }}</td>
<td>{{ row.data.chip || '-' }}</td>
<td>{{ row.data.conta || '-' }}</td>
<td>{{ row.data.status || '-' }}</td>
<td class="validation-cell">
<div class="batch-row-valid" *ngIf="(row.errors.length || 0) === 0">
<i class="bi bi-check-circle-fill"></i> OK
</div>
<div
class="batch-row-errors-compact"
*ngIf="(row.errors.length || 0) > 0"
[attr.title]="getBatchExcelRowErrorsTitle(row)"
>
<div class="batch-row-error-main">
{{ getBatchExcelRowPrimaryError(row) }}
</div>
<div class="batch-row-more" *ngIf="(row.errors.length || 0) > 1">
+{{ (row.errors.length || 0) - 1 }} pendência(s)
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="batch-mass-preview-foot" *ngIf="(batchMassPreview?.recognizedRows || 0) > 5">
Mostrando 5 de {{ batchMassPreview?.recognizedRows }} linha(s) na prévia.
<div class="batch-mass-preview-foot" *ngIf="(excelPreview.rows.length || 0) > (batchExcelPreviewRowsPreview.length || 0)">
Mostrando {{ batchExcelPreviewRowsPreview.length }} de {{ excelPreview.rows.length || 0 }} linha(s) na prévia da planilha.
</div>
</div>
</div>
@ -1160,8 +1101,8 @@
</div>
<div class="batch-lines-empty" *ngIf="createBatchCount === 0">
Nenhuma linha no lote ainda. Use a <strong>Entrada em Massa</strong> acima para colar/digitar as linhas em
sequência e carregá-las na grade.
Nenhuma linha no lote ainda. Use a <strong>importação por planilha</strong> acima para pré-visualizar e
carregar as linhas na grade.
</div>
<div class="batch-editor-layout" *ngIf="createBatchCount > 0">
@ -1272,7 +1213,7 @@
<div class="batch-selected-hint">
<i class="bi bi-cursor-fill"></i>
Após carregar o lote pela <strong>Entrada em Massa</strong>, selecione uma linha e clique em
Após carregar o lote pela <strong>importação da planilha</strong>, selecione uma linha e clique em
<strong>Detalhes</strong> para preencher `Contrato`, `Datas`, `Financeiro` e demais campos obrigatórios do
cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
</div>
@ -1525,6 +1466,198 @@
</div>
</div>
<!-- MOVE TO RESERVA MODAL -->
<div
*ngIf="moveToReservaOpen"
class="modal-card modal-lg modal-move-reserva"
(click)="$event.stopPropagation()"
>
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg warning"><i class="bi bi-box-arrow-left"></i></span>
Enviar Linhas para Reserva
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="moveToReservaSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-sm btn-send-reserva-group" (click)="submitMoveToReserva()" [disabled]="moveToReservaSaving || reservaSelectedCount === 0">
<span *ngIf="!moveToReservaSaving"><i class="bi bi-check2-circle me-1"></i> Confirmar Envio</span>
<span *ngIf="moveToReservaSaving"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-info-circle me-2"></i>Confirmação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="small text-muted mb-2">
As linhas selecionadas serão movidas para a <strong>Reserva</strong> e ficarão disponíveis para reatribuição.
</div>
<div class="reserva-confirmation-pills">
<div class="summary-pill total">Cliente: {{ expandedGroup || '-' }}</div>
<div class="summary-pill warn">Selecionadas: {{ reservaSelectedCount }}</div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-list-check me-2"></i>Linhas Selecionadas ({{ reservaSelectedCount }})</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="table-wrap inner-table-wrap" style="max-height: 520px;">
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CHIP (ICCID)</th>
<th>USUÁRIO</th>
</tr>
</thead>
<tbody>
<tr *ngIf="reservaSelectedLines.length === 0">
<td colspan="4" class="text-muted py-3">Nenhuma linha selecionada.</td>
</tr>
<tr *ngFor="let r of reservaSelectedLines">
<td>{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha || '-' }}</td>
<td>{{ r.chip || '-' }}</td>
<td>{{ r.usuario || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- RESERVA TRANSFER MODAL -->
<div
*ngIf="reservaTransferOpen"
class="modal-card modal-lg modal-reserva-transfer"
(click)="$event.stopPropagation()"
>
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-arrow-left-right"></i></span>
Atribuir Linhas da Reserva
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-glass btn-sm" (click)="closeAllModals()" [disabled]="reservaTransferSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button class="btn btn-brand btn-sm" (click)="submitReservaTransfer()" [disabled]="reservaTransferSaving || reservaSelectedCount === 0">
<span *ngIf="!reservaTransferSaving"><i class="bi bi-check2-circle me-1"></i> Confirmar Atribuição</span>
<span *ngIf="reservaTransferSaving"><span class="spinner-border spinner-border-sm me-2"></span> Processando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i>Destino da Atribuição</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Cliente de destino <span class="text-danger">*</span></label>
<app-select
class="form-select"
size="sm"
[options]="reservaTransferTargetClientsOptions"
[placeholder]="'Selecione o cliente'"
[(ngModel)]="reservaTransferModel.clienteDestino"
></app-select>
</div>
<div class="form-field span-2">
<label>Usuário (opcional)</label>
<input
class="form-control form-control-sm"
[(ngModel)]="reservaTransferModel.usuarioDestino"
placeholder="Se informado, substitui o usuário nas linhas selecionadas"
/>
</div>
<div class="form-field span-2">
<label>Skil (opcional)</label>
<app-select
class="form-select"
size="sm"
[options]="skilOptions"
[placeholder]="'Manter/inferir automaticamente'"
[(ngModel)]="reservaTransferModel.skilDestino"
></app-select>
</div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box" open>
<summary class="box-header">
<span><i class="bi bi-list-check me-2"></i>Linhas Selecionadas ({{ reservaSelectedCount }})</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="table-wrap inner-table-wrap" style="max-height: 460px;">
<table class="table table-modern table-compact align-middle text-center mb-0">
<thead>
<tr>
<th>ITEM</th>
<th>LINHA</th>
<th>CHIP (ICCID)</th>
<th>USUÁRIO</th>
</tr>
</thead>
<tbody>
<tr *ngIf="reservaSelectedLines.length === 0">
<td colspan="4" class="text-muted py-3">Nenhuma linha selecionada.</td>
</tr>
<tr *ngFor="let r of reservaSelectedLines">
<td>{{ r.item }}</td>
<td class="fw-black text-blue">{{ r.linha || '-' }}</td>
<td>{{ r.chip || '-' }}</td>
<td>{{ r.usuario || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="small text-muted mt-2">
Somente linhas que ainda estiverem aptas na <strong>Reserva</strong> serão atribuídas. O backend retorna sucesso/erro por linha.
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- DETAIL MODAL -->
<div
*ngIf="detailOpen"

View File

@ -336,6 +336,118 @@
inset 0 1px 0 rgba(255,255,255,0.16);
}
}
.btn-send-reserva-group {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.42rem 0.85rem;
line-height: 1;
white-space: nowrap;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
background:
linear-gradient(135deg, rgba(3, 15, 170, 0.96), rgba(227, 61, 207, 0.9));
box-shadow:
0 10px 22px rgba(3, 15, 170, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease, opacity 0.18s ease;
i {
font-weight: 900;
}
&:hover:not(:disabled) {
color: #fff;
transform: translateY(-1px);
filter: saturate(1.05) brightness(1.02);
box-shadow:
0 14px 26px rgba(227, 61, 207, 0.16),
0 8px 18px rgba(3, 15, 170, 0.14);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24),
0 12px 24px rgba(227, 61, 207, 0.14);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow:
0 8px 16px rgba(3, 15, 170, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: 0 6px 14px rgba(17, 18, 20, 0.12);
}
}
.line-select-checkbox {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 6px;
border: 1.5px solid rgba(3, 15, 170, 0.35);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 246, 255, 0.95));
display: inline-grid;
place-content: center;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.12s ease;
position: relative;
vertical-align: middle;
&::after {
content: "";
width: 5px;
height: 9px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg) scale(0);
transform-origin: center;
transition: transform 0.16s ease;
margin-top: -1px;
}
&:hover {
border-color: rgba(227, 61, 207, 0.65);
box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.14);
}
&:checked {
border-color: rgba(3, 15, 170, 0.95);
background: linear-gradient(135deg, rgba(3, 15, 170, 0.98), rgba(227, 61, 207, 0.95));
box-shadow:
0 0 0 3px rgba(3, 15, 170, 0.16),
0 4px 10px rgba(3, 15, 170, 0.24);
}
&:checked::after {
transform: rotate(45deg) scale(1);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 6px rgba(3, 15, 170, 0.24);
}
&:active {
transform: scale(0.96);
}
}
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
/* Inner Table Destravada */
@ -351,6 +463,22 @@
.action-group { display: flex; justify-content: center; gap: 8px; }
.btn-icon { width: 32px; height: 32px; border: none; background: transparent; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: rgba(17,18,20,0.5); transition: all 0.2s; cursor: pointer; &:hover { background: rgba(17,18,20,0.05); color: var(--text); transform: translateY(-1px); } &.primary:hover { color: var(--blue); background: rgba(3,15,170,0.1); } &.danger:hover { color: var(--danger-text); background: var(--danger-bg); } }
/* Evita corte dos botões na última coluna de ações (grupos e tabela) */
.table-modern.table-modern-responsive th.actions-col-main,
.table-modern.table-modern-responsive td.actions-col-main {
min-width: 176px;
}
.table-modern.table-modern-responsive td.actions-col-main {
overflow: visible !important;
text-overflow: clip !important;
white-space: nowrap;
}
.table-modern.table-modern-responsive td.actions-col-main .action-group {
flex-wrap: nowrap;
}
/* Footer */
.geral-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); } }
@ -378,6 +506,54 @@
.modal-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
.modal-card.modal-move-reserva {
width: min(1520px, 99vw);
max-height: 94vh;
.details-dashboard {
grid-template-columns: minmax(420px, 0.9fr) minmax(700px, 1.45fr);
gap: 16px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.modal-body {
padding: 20px 22px;
}
.reserva-confirmation-pills {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
.summary-pill {
margin: 0;
white-space: normal;
line-height: 1.25;
}
}
}
.modal-card.modal-reserva-transfer {
width: min(1480px, 99vw);
max-height: 94vh;
.details-dashboard {
grid-template-columns: minmax(420px, 0.95fr) minmax(560px, 1.25fr);
gap: 16px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.modal-body {
padding: 20px 22px;
}
}
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */

View File

@ -45,6 +45,7 @@ interface LineRow {
id: string;
item: string;
linha: string;
chip?: string;
cliente: string;
usuario: string;
status: string;
@ -63,6 +64,7 @@ interface ApiLineList {
id: string;
item: number;
linha: string | null;
chip?: string | null;
cliente: string | null;
usuario: string | null;
vencConta: string | null;
@ -170,6 +172,68 @@ interface CreateMobileLinesBatchResponse {
}>;
}
interface BatchExcelIssueDto {
column?: string | null;
message: string;
}
interface BatchExcelPreviewRowDto {
sourceRowNumber: number;
sourceItem?: number | null;
generatedItemPreview?: number | null;
valid: boolean;
duplicateLinhaInFile?: boolean;
duplicateChipInFile?: boolean;
duplicateLinhaInSystem?: boolean;
duplicateChipInSystem?: boolean;
data: Partial<CreateMobileLineRequest>;
errors: BatchExcelIssueDto[];
warnings: BatchExcelIssueDto[];
}
interface BatchExcelPreviewResultDto {
fileName?: string | null;
sheetName?: string | null;
nextItemStart: number;
totalRows: number;
validRows: number;
invalidRows: number;
duplicateRows: number;
canProceed: boolean;
headerErrors: BatchExcelIssueDto[];
headerWarnings: BatchExcelIssueDto[];
rows: BatchExcelPreviewRowDto[];
}
interface AssignReservaLinesRequestDto {
clienteDestino: string;
usuarioDestino?: string | null;
skilDestino?: string | null;
lineIds: string[];
}
interface MoveLinesToReservaRequestDto {
lineIds: string[];
}
interface AssignReservaLineItemResultDto {
id: string;
item?: number;
linha?: string | null;
chip?: string | null;
clienteAnterior?: string | null;
clienteNovo?: string | null;
success: boolean;
message: string;
}
interface AssignReservaLinesResultDto {
requested: number;
updated: number;
failed: number;
items: AssignReservaLineItemResultDto[];
}
@Component({
standalone: true,
@ -183,6 +247,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
@ViewChild('batchExcelInput') batchExcelInput?: ElementRef<HTMLInputElement>;
@ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>;
@ViewChild('createModal', { static: false }) createModal!: ElementRef<HTMLElement>;
@ -203,6 +268,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
private readonly templatesApiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/templates`;
})();
loading = false;
isAdmin = false;
@ -257,9 +327,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
batchMassInputText = '';
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
batchMassPreview: BatchMassPreviewResult | null = null;
batchExcelPreview: BatchExcelPreviewResultDto | null = null;
batchExcelPreviewLoading = false;
batchExcelTemplateDownloading = false;
batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD';
createBatchValidationByUid: Record<number, BatchLineValidation> = {};
createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
reservaSelectedLineIds: string[] = [];
reservaTransferOpen = false;
reservaTransferSaving = false;
moveToReservaOpen = false;
moveToReservaSaving = false;
reservaTransferClients: string[] = [];
reservaTransferModel: { clienteDestino: string; usuarioDestino: string; skilDestino: string } = {
clienteDestino: '',
usuarioDestino: '',
skilDestino: ''
};
reservaTransferLastResult: AssignReservaLinesResultDto | null = null;
moveToReservaLastResult: AssignReservaLinesResultDto | null = null;
detailData: any = null;
financeData: any = null;
editModel: any = null;
@ -450,6 +538,63 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return rows.slice(0, 5).map((row) => ({ line: row.sourceLineNumber, data: row.data, errors: row.errors }));
}
get batchExcelPreviewRowsPreview(): BatchExcelPreviewRowDto[] {
return (this.batchExcelPreview?.rows ?? []).slice(0, 8);
}
getBatchExcelRowErrorsTitle(row: BatchExcelPreviewRowDto | null | undefined): string {
const errors = row?.errors ?? [];
return errors
.map((e) => `${e?.column ? `${e.column}: ` : ''}${e?.message ?? ''}`.trim())
.filter(Boolean)
.join(' | ');
}
getBatchExcelRowPrimaryError(row: BatchExcelPreviewRowDto | null | undefined): string {
const first = row?.errors?.[0];
if (!first) return '';
return `${first.column ? `${first.column}: ` : ''}${first.message ?? ''}`.trim();
}
get isReservaExpandedGroup(): boolean {
return this.filterSkil === 'RESERVA' && !!(this.expandedGroup ?? '').trim();
}
get isExpandedGroupNamedReserva(): boolean {
return (this.expandedGroup ?? '').toString().trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0;
}
get hasGroupLineSelectionTools(): boolean {
return !!(this.expandedGroup ?? '').trim();
}
get canMoveSelectedLinesToReserva(): boolean {
return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva;
}
get reservaSelectedCount(): number {
return this.reservaSelectedLineIds.length;
}
get reservaSelectedLines(): LineRow[] {
if (!this.hasGroupLineSelectionTools || this.reservaSelectedLineIds.length === 0) return [];
const ids = new Set(this.reservaSelectedLineIds);
return this.groupLines.filter((x) => ids.has(x.id));
}
get reservaTransferTargetClientsOptions(): string[] {
const set = new Set<string>();
for (const c of this.reservaTransferClients) {
const v = (c ?? '').toString().trim();
if (!v) continue;
if (v.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) continue;
set.add(v);
}
const current = (this.reservaTransferModel?.clienteDestino ?? '').toString().trim();
if (current && current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0) set.add(current);
return Array.from(set).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
}
get isGroupMode(): boolean {
return this.viewMode === 'GROUPS';
}
@ -628,7 +773,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
// ============================================================
private anyModalOpen(): boolean {
return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen);
return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen);
}
private cleanupModalArtifacts() {
@ -654,6 +799,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.financeOpen = false;
this.editOpen = false;
this.createOpen = false;
this.reservaTransferOpen = false;
this.moveToReservaOpen = false;
this.detailData = null;
this.financeData = null;
@ -665,6 +812,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.editingId = null;
this.batchDetailOpen = false;
this.batchMassPreview = null;
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
this.batchExcelTemplateDownloading = false;
this.reservaTransferSaving = false;
this.moveToReservaSaving = false;
this.reservaTransferLastResult = null;
this.moveToReservaLastResult = null;
// Limpa overlays/locks residuais
this.cleanupModalArtifacts();
@ -1377,8 +1531,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
toggleGroup(clientName: string) {
if (this.expandedGroup === clientName) {
this.expandedGroup = null;
this.groupLines = [];
this.clearReservaSelection();
return;
}
this.clearReservaSelection();
this.expandedGroup = clientName;
const term = (this.searchTerm ?? '').trim();
@ -1390,6 +1547,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
fetchGroupLines(clientName: string, search?: string) {
const requestVersion = ++this.linesRequestVersion;
this.groupLines = [];
this.clearReservaSelection();
this.loadingLines = true;
let params = new HttpParams()
@ -1410,6 +1568,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
id: x.id,
item: String(x.item ?? ''),
linha: x.linha ?? '',
chip: x.chip ?? '',
cliente: x.cliente ?? '',
usuario: x.usuario ?? '',
status: x.status ?? '',
@ -1876,6 +2035,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.batchMassInputText = '';
this.batchMassSeparatorMode = 'AUTO';
this.batchMassPreview = null;
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
this.createBatchValidationByUid = {};
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
this.createSaving = false;
@ -2365,12 +2526,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
private recomputeBatchValidation() {
const byUid: Record<number, BatchLineValidation> = {};
const counts = new Map<string, number>();
const linhaCounts = new Map<string, number>();
const chipCounts = new Map<string, number>();
this.createBatchLines.forEach((row) => {
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
if (!linhaDigits) return;
counts.set(linhaDigits, (counts.get(linhaDigits) ?? 0) + 1);
linhaCounts.set(linhaDigits, (linhaCounts.get(linhaDigits) ?? 0) + 1);
});
this.createBatchLines.forEach((row) => {
const chipDigits = (row?.chip ?? '').toString().replace(/\D/g, '');
if (!chipDigits) return;
chipCounts.set(chipDigits, (chipCounts.get(chipDigits) ?? 0) + 1);
});
let valid = 0;
@ -2381,12 +2549,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
const linhaRaw = (row?.linha ?? '').toString().trim();
const chipRaw = (row?.chip ?? '').toString().trim();
const linhaDigits = linhaRaw.replace(/\D/g, '');
const chipDigits = chipRaw.replace(/\D/g, '');
const errors: string[] = [];
if (!linhaRaw) errors.push('Linha obrigatória.');
else if (!linhaDigits) errors.push('Número de linha inválido.');
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
else if (!chipDigits) errors.push('Chip (ICCID) inválido.');
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
const conta = (row?.['conta'] ?? '').toString().trim();
@ -2402,9 +2572,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.');
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1;
if (isDuplicate) {
const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1;
const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1;
if (isLinhaDuplicate) {
errors.push('Linha duplicada no lote.');
}
if (isChipDuplicate) {
errors.push('Chip (ICCID) duplicado no lote.');
}
if (isLinhaDuplicate || isChipDuplicate) {
duplicates++;
}
@ -2638,6 +2814,372 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
});
}
isReservaLineSelected(id: string): boolean {
return this.reservaSelectedLineIds.includes(id);
}
toggleReservaLineSelection(id: string, checked?: boolean) {
if (!id || !this.hasGroupLineSelectionTools) return;
const exists = this.reservaSelectedLineIds.includes(id);
const shouldSelect = typeof checked === 'boolean' ? checked : !exists;
if (shouldSelect && !exists) {
this.reservaSelectedLineIds = [...this.reservaSelectedLineIds, id];
return;
}
if (!shouldSelect && exists) {
this.reservaSelectedLineIds = this.reservaSelectedLineIds.filter((x) => x !== id);
}
}
toggleSelectAllReservaGroupLines() {
if (!this.hasGroupLineSelectionTools) return;
const ids = (this.groupLines ?? []).map((x) => x.id).filter(Boolean);
if (ids.length === 0) {
this.reservaSelectedLineIds = [];
return;
}
if (this.reservaSelectedLineIds.length === ids.length && ids.every((id) => this.reservaSelectedLineIds.includes(id))) {
this.reservaSelectedLineIds = [];
return;
}
this.reservaSelectedLineIds = [...ids];
}
clearReservaSelection() {
if (this.reservaSelectedLineIds.length === 0) return;
this.reservaSelectedLineIds = [];
}
async openReservaTransferModal() {
if (!this.isReservaExpandedGroup) {
await this.showToast('Abra um grupo no filtro Reserva para selecionar e atribuir linhas.');
return;
}
if (this.reservaSelectedCount <= 0) {
await this.showToast('Selecione ao menos uma linha da Reserva.');
return;
}
this.reservaTransferOpen = true;
this.reservaTransferSaving = false;
this.reservaTransferLastResult = null;
this.reservaTransferModel = {
clienteDestino: '',
usuarioDestino: '',
skilDestino: ''
};
this.cdr.detectChanges();
this.loadReservaTransferClients();
}
async openMoveToReservaModal() {
if (!this.hasGroupLineSelectionTools || !this.expandedGroup) {
await this.showToast('Abra um grupo para selecionar linhas.');
return;
}
if (this.isReservaExpandedGroup || this.isExpandedGroupNamedReserva) {
await this.showToast('Esse grupo já está no contexto da Reserva.');
return;
}
if (this.reservaSelectedCount <= 0) {
await this.showToast('Selecione ao menos uma linha para enviar à Reserva.');
return;
}
this.moveToReservaOpen = true;
this.moveToReservaSaving = false;
this.moveToReservaLastResult = null;
this.cdr.detectChanges();
}
private loadReservaTransferClients() {
this.http.get<string[]>(`${this.apiBase}/clients`).subscribe({
next: (clients) => {
this.reservaTransferClients = (clients ?? []).filter((x) => !!(x ?? '').toString().trim());
this.cdr.detectChanges();
},
error: () => {
this.reservaTransferClients = [];
}
});
}
async submitReservaTransfer() {
if (this.reservaTransferSaving) return;
const clienteDestino = (this.reservaTransferModel.clienteDestino ?? '').toString().trim();
if (!clienteDestino) {
await this.showToast('Informe o cliente de destino.');
return;
}
if (clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) {
await this.showToast('O cliente de destino não pode ser RESERVA.');
return;
}
if (this.reservaSelectedCount <= 0) {
await this.showToast('Selecione ao menos uma linha da Reserva.');
return;
}
const payload: AssignReservaLinesRequestDto = {
clienteDestino,
usuarioDestino: (this.reservaTransferModel.usuarioDestino ?? '').toString().trim() || null,
skilDestino: (this.reservaTransferModel.skilDestino ?? '').toString().trim() || null,
lineIds: [...this.reservaSelectedLineIds]
};
this.reservaTransferSaving = true;
this.http.post<AssignReservaLinesResultDto>(`${this.apiBase}/reserva/assign-client`, payload).subscribe({
next: async (res) => {
this.reservaTransferSaving = false;
this.reservaTransferLastResult = res;
const ok = Number(res?.updated ?? 0) || 0;
const failed = Number(res?.failed ?? 0) || 0;
if (ok > 0) {
this.clearReservaSelection();
this.reservaTransferOpen = false;
await this.showToast(
failed > 0
? `Transferência concluída com pendências: ${ok} linha(s) atribuída(s), ${failed} falha(s).`
: `${ok} linha(s) da Reserva atribuída(s) com sucesso.`
);
if (this.expandedGroup) {
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
this.loadKpis();
return;
}
const firstError = res?.items?.find((x) => !x.success)?.message || 'Nenhuma linha foi atribuída.';
await this.showToast(firstError);
},
error: async (err: HttpErrorResponse) => {
this.reservaTransferSaving = false;
const msg = (err.error as any)?.message || 'Erro ao atribuir linhas da Reserva.';
await this.showToast(msg);
}
});
}
async submitMoveToReserva() {
if (this.moveToReservaSaving) return;
if (!this.canMoveSelectedLinesToReserva) {
await this.showToast('Selecione linhas de um cliente para enviar à Reserva.');
return;
}
if (this.reservaSelectedCount <= 0) {
await this.showToast('Selecione ao menos uma linha para enviar à Reserva.');
return;
}
const payload: MoveLinesToReservaRequestDto = {
lineIds: [...this.reservaSelectedLineIds]
};
this.moveToReservaSaving = true;
this.http.post<AssignReservaLinesResultDto>(`${this.apiBase}/move-to-reserva`, payload).subscribe({
next: async (res) => {
this.moveToReservaSaving = false;
this.moveToReservaLastResult = res;
const ok = Number(res?.updated ?? 0) || 0;
const failed = Number(res?.failed ?? 0) || 0;
if (ok > 0) {
this.clearReservaSelection();
this.moveToReservaOpen = false;
await this.showToast(
failed > 0
? `Envio para Reserva concluído com pendências: ${ok} linha(s) enviada(s), ${failed} falha(s).`
: `${ok} linha(s) enviada(s) para a Reserva com sucesso.`
);
if (this.expandedGroup) {
const term = (this.searchTerm ?? '').trim();
const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined;
this.fetchGroupLines(this.expandedGroup, useTerm);
}
this.loadGroups();
this.loadKpis();
return;
}
const firstError = res?.items?.find((x) => !x.success)?.message || 'Nenhuma linha foi enviada para a Reserva.';
await this.showToast(firstError);
},
error: async (err: HttpErrorResponse) => {
this.moveToReservaSaving = false;
const msg = (err.error as any)?.message || 'Erro ao enviar linhas para a Reserva.';
await this.showToast(msg);
}
});
}
async onDownloadBatchExcelTemplate() {
if (this.batchExcelTemplateDownloading) return;
this.batchExcelTemplateDownloading = true;
const params = new HttpParams().set('_', `${Date.now()}`);
this.http.get(`${this.templatesApiBase}/planilha-geral`, { params, observe: 'response', responseType: 'blob' }).subscribe({
next: async (res) => {
this.batchExcelTemplateDownloading = false;
const blob = res.body;
if (!blob) {
await this.showToast('Não foi possível baixar o modelo da planilha.');
return;
}
const disposition = res.headers.get('content-disposition') || '';
const fileName = this.extractDownloadFileName(disposition) || 'MODELO_GERAL_LINEGESTAO.xlsx';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
},
error: async (err: HttpErrorResponse) => {
this.batchExcelTemplateDownloading = false;
const msg = (err.error as any)?.message || 'Erro ao baixar o modelo da planilha.';
await this.showToast(msg);
}
});
}
private extractDownloadFileName(contentDisposition: string): string | null {
const raw = (contentDisposition ?? '').trim();
if (!raw) return null;
const utf8Match = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, '$1'));
} catch {
return utf8Match[1].trim().replace(/^"(.*)"$/, '$1');
}
}
const simpleMatch = raw.match(/filename\s*=\s*([^;]+)/i);
if (!simpleMatch?.[1]) return null;
return simpleMatch[1].trim().replace(/^"(.*)"$/, '$1');
}
async onImportBatchExcel() {
if (this.createSaving) return;
if (!this.isCreateBatchMode) {
await this.showToast('Ative o modo Lote de Linhas para importar a planilha.');
return;
}
if (!this.batchExcelInput?.nativeElement) return;
this.batchExcelInput.nativeElement.value = '';
this.batchExcelInput.nativeElement.click();
}
onBatchExcelSelected(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
this.batchExcelPreviewLoading = true;
this.batchExcelPreview = null;
this.http.post<BatchExcelPreviewResultDto>(`${this.apiBase}/batch/import-preview`, form).subscribe({
next: (preview) => {
this.batchExcelPreviewLoading = false;
this.batchExcelPreview = preview;
this.cdr.detectChanges();
},
error: async (err: HttpErrorResponse) => {
this.batchExcelPreviewLoading = false;
this.batchExcelPreview = null;
const msg = (err.error as any)?.message || 'Falha ao pré-visualizar a planilha do lote.';
await this.showToast(msg);
}
});
}
clearBatchExcelPreview() {
this.batchExcelPreview = null;
this.batchExcelPreviewLoading = false;
}
private mapBatchExcelPreviewRowToSeed(row: BatchExcelPreviewRowDto): Partial<CreateBatchLineDraft> {
const data = row?.data ?? {};
return {
...data,
item: 0,
linha: (data.linha ?? '').toString(),
chip: (data.chip ?? '').toString(),
usuario: (data.usuario ?? '').toString(),
tipoDeChip: (data.tipoDeChip ?? '').toString(),
dataBloqueio: this.isoToDateInput(data.dataBloqueio as any),
dataEntregaOpera: this.isoToDateInput(data.dataEntregaOpera as any),
dataEntregaCliente: this.isoToDateInput(data.dataEntregaCliente as any),
dtEfetivacaoServico: this.isoToDateInput(data.dtEfetivacaoServico as any),
dtTerminoFidelizacao: this.isoToDateInput(data.dtTerminoFidelizacao as any)
};
}
async applyBatchExcelPreview(mode: BatchMassApplyMode) {
const preview = this.batchExcelPreview;
if (!preview) {
await this.showToast('Importe uma planilha para gerar a pré-visualização.');
return;
}
if ((preview.headerErrors?.length ?? 0) > 0) {
await this.showToast(preview.headerErrors[0]?.message || 'Corrija os erros de cabeçalho antes de aplicar.');
return;
}
const validRows = (preview.rows ?? []).filter((row) => row.valid);
if (validRows.length <= 0) {
await this.showToast('Nenhuma linha válida encontrada na planilha para carregar no lote.');
return;
}
const parsedRows = validRows.map((row) =>
this.createBatchDraftFromSource(
this.createModel,
this.mapBatchExcelPreviewRowToSeed(row),
{ keepLinha: true, keepChip: true, copyDetails: true }
)
);
this.createBatchLines = mergeMassRows(this.createBatchLines, parsedRows, mode);
this.selectedBatchLineUid = parsedRows[parsedRows.length - 1]?.uid ?? this.selectedBatchLineUid;
this.batchDetailOpen = this.createBatchLines.length > 0;
this.recomputeBatchValidation();
await this.showToast(
mode === 'REPLACE'
? `${parsedRows.length} linha(s) válida(s) carregada(s) da planilha (substituindo o lote atual).`
: `${parsedRows.length} linha(s) válida(s) adicionada(s) ao lote pela planilha.`
);
}
private async saveCreateBatch() {
const clientError = this.validateCreateClientFields();
if (clientError) {

View File

@ -87,11 +87,11 @@
[disabled]="loading">
</app-select>
</div>
<div class="filter-field">
<label>Usuário (ID)</label>
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" />
<div class="filter-field filter-user">
<label>Usuário</label>
<input type="text" placeholder="Nome ou e-mail do usuário" [(ngModel)]="filterUser" [disabled]="loading" />
</div>
<div class="filter-field">
<div class="filter-field filter-search">
<label>Busca geral</label>
<div class="input-group input-group-sm search-group">
<span class="input-group-text">
@ -150,7 +150,7 @@
<td>
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
</td>
<td>
<td class="entity-col">
<div class="entity-cell">
<div class="entity-label td-clip" [title]="displayEntity(log)">
{{ displayEntity(log) }}
@ -164,7 +164,6 @@
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
</button>
</div>
<small class="entity-id" *ngIf="log.entityId">{{ log.entityId }}</small>
</td>
</tr>
<tr class="details-row" *ngIf="expandedLogId === log.id">

View File

@ -214,6 +214,7 @@
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
label {
font-size: 11px;
@ -224,6 +225,9 @@
}
input {
width: 100%;
max-width: 100%;
min-width: 0;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.15);
@ -244,11 +248,25 @@
grid-column: span 2;
}
.filter-user {
min-width: 0;
width: 100%;
input {
width: 100%;
max-width: 100%;
}
}
.search-group {
max-width: 270px;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 40px;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
align-items: stretch;
background: #fff;
border: 1px solid rgba(17, 18, 20, 0.15);
@ -262,6 +280,7 @@
}
.input-group-text {
flex: 0 0 auto;
background: transparent;
border: none;
color: rgba(17, 18, 20, 0.5);
@ -272,10 +291,13 @@
}
.form-control {
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: none;
background: transparent;
height: auto;
padding: 10px 0;
height: 40px;
padding: 0 8px;
font-size: 0.9rem;
color: var(--text);
box-shadow: none;
@ -285,6 +307,7 @@
}
.btn-clear {
flex: 0 0 auto;
border: none;
background: transparent;
color: rgba(17, 18, 20, 0.45);
@ -399,7 +422,8 @@
.table-modern th:nth-child(5),
.table-modern td:nth-child(5) {
text-align: left;
text-align: center;
min-width: 240px;
}
.table-modern th:nth-child(2),
@ -447,13 +471,15 @@
.entity-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
justify-content: center;
gap: 8px;
}
.entity-label {
font-weight: 700;
color: var(--text);
text-align: center;
max-width: 300px;
}
.entity-id {
@ -677,10 +703,10 @@
.entity-cell {
flex-direction: row;
align-items: center;
justify-content: flex-start;
justify-content: center;
gap: 6px;
}
.entity-label { flex: 1 1 auto; min-width: 0; }
.entity-label { flex: 1 1 auto; min-width: 0; text-align: center; }
.expand-btn { align-self: center; flex-shrink: 0; }
}
@ -870,18 +896,14 @@
}
.entity-cell {
justify-content: flex-start;
justify-content: center;
gap: 6px;
}
.entity-label {
min-width: 0;
flex: 1 1 auto;
}
.entity-id {
margin-top: 2px;
line-height: 1.2;
text-align: center;
}
.details-row td {

View File

@ -36,7 +36,7 @@ export class Historico implements OnInit {
filterPageName = '';
filterAction = '';
filterUserId = '';
filterUser = '';
filterSearch = '';
dateFrom = '';
dateTo = '';
@ -84,7 +84,7 @@ export class Historico implements OnInit {
clearFilters(): void {
this.filterPageName = '';
this.filterAction = '';
this.filterUserId = '';
this.filterUser = '';
this.filterSearch = '';
this.dateFrom = '';
this.dateTo = '';
@ -221,7 +221,7 @@ export class Historico implements OnInit {
pageSize: this.pageSize,
pageName: this.filterPageName || undefined,
action: this.filterAction || undefined,
userId: this.filterUserId?.trim() || undefined,
user: this.filterUser?.trim() || undefined,
search: this.filterSearch?.trim() || undefined,
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
dateTo: this.toIsoDate(this.dateTo, true) || undefined,

View File

@ -30,7 +30,7 @@
<i class="bi bi-search"></i>
<input
type="text"
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
placeholder="Pesquisar..."
[(ngModel)]="search"
(ngModelChange)="clearSelection()"
/>
@ -115,8 +115,8 @@
class="list-item"
*ngFor="let n of filteredNotifications"
[class.is-read]="n.lida"
[class.is-danger]="getNotificationTipo(n) === 'Vencido'"
[class.is-warning]="getNotificationTipo(n) === 'AVencer'"
[class.is-danger]="isVencido(n)"
[class.is-warning]="isAVencer(n)"
>
<div class="status-strip"></div>
@ -126,7 +126,12 @@
</label>
<div class="item-icon">
<i class="bi" [class.bi-x-circle-fill]="getNotificationTipo(n) === 'Vencido'" [class.bi-clock-fill]="getNotificationTipo(n) === 'AVencer'"></i>
<i
class="bi"
[class.bi-x-circle-fill]="isVencido(n)"
[class.bi-clock-fill]="isAVencer(n)"
[class.bi-check2-circle]="isAutoRenew(n)">
</i>
</div>
<div class="item-content">
@ -156,8 +161,8 @@
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
</div>
<div class="meta-row">
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
<span class="badge-tag" [class.danger]="isVencido(n)" [class.warn]="isAVencer(n)" [class.info]="isAutoRenew(n)">
{{ getStatusLabel(n) }}
</span>
</div>
</div>
@ -173,6 +178,27 @@
<i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i>
<span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span>
</button>
<button
type="button"
class="btn-action ghost"
*ngIf="n.vigenciaLineId || n.linha"
title="Abrir na página de vigência"
(click)="goToVigencia(n)"
>
<i class="bi bi-box-arrow-up-right"></i>
<span class="d-none d-md-inline">Abrir vigência</span>
</button>
<button
type="button"
class="btn-action renew"
*ngIf="isAVencer(n)"
title="Programar renovação automática por mais 2 anos"
(click)="renewFromNotification(n)"
[disabled]="isRenewing(n)"
>
<i class="bi bi-arrow-repeat"></i>
<span class="d-none d-md-inline">{{ isRenewing(n) ? 'Aguarde...' : 'Renovar +2' }}</span>
</button>
</div>
</div>

View File

@ -260,6 +260,7 @@ $border: #e5e7eb;
.bi-x-circle-fill { color: $danger; }
.bi-clock-fill { color: $warning; }
.bi-check2-circle { color: $primary; }
}
.item-content { flex: 1; min-width: 0; }
@ -290,9 +291,9 @@ $border: #e5e7eb;
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
align-items: flex-start;
min-width: 170px;
text-align: right;
text-align: left;
}
.date-pill {
@ -323,17 +324,22 @@ $border: #e5e7eb;
&.danger { background: rgba($danger, 0.1); color: $danger; }
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
&.info { background: rgba($primary, 0.12); color: $primary; }
}
.item-actions {
margin-left: 12px; align-self: center;
margin-left: 12px;
align-self: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-action {
background: white; border: 1px solid $border;
padding: 8px 12px; border-radius: 8px;
padding: 6px 10px; border-radius: 7px;
cursor: pointer;
color: $text-main; font-size: 13px; font-weight: 600;
color: $text-main; font-size: 12px; font-weight: 600;
display: flex; align-items: center; gap: 6px;
transition: all 0.2s;
@ -344,6 +350,18 @@ $border: #e5e7eb;
@media(min-width: 768px) { opacity: 0.6; }
}
.btn-action.ghost {
background: rgba($primary, 0.06);
border-color: rgba($primary, 0.25);
color: $primary;
}
.btn-action.renew {
background: rgba($warning, 0.12);
border-color: rgba($warning, 0.35);
color: color.adjust($warning, $lightness: -22%);
}
/* ==========================================================================
RESPONSIVIDADE MOBILE (Central de Notificações)
========================================================================== */
@ -634,14 +652,17 @@ $border: #e5e7eb;
grid-column: 1 / -1;
margin: 2px 0 0 0;
width: 100%;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.btn-action {
width: 100%;
justify-content: center;
padding: 8px 10px;
border-radius: 10px;
font-size: 12px;
padding: 6px 8px;
border-radius: 9px;
font-size: 11px;
gap: 6px;
}
@ -727,7 +748,7 @@ $border: #e5e7eb;
}
.btn-action {
font-size: 11px;
padding: 7px 8px;
font-size: 10px;
padding: 6px 7px;
}
}

View File

@ -1,9 +1,11 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
import { VigenciaService } from '../../services/vigencia.service';
@Component({
selector: 'app-notificacoes',
@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy {
bulkUnreadLoading = false;
exportLoading = false;
selectedIds = new Set<string>();
renewingKey: string | null = null;
private readonly subs = new Subscription();
constructor(private notificationsService: NotificationsService) {}
constructor(
private notificationsService: NotificationsService,
private router: Router,
private vigenciaService: VigenciaService
) {}
ngOnInit(): void {
this.loadNotifications();
@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed.toLocaleDateString('pt-BR');
}
getNotificationTipo(notification: NotificationDto): 'Vencido' | 'AVencer' {
getNotificationTipo(notification: NotificationDto): string {
if (notification.tipo === 'RenovacaoAutomatica') {
return 'RenovacaoAutomatica';
}
const reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
const parsed = this.parseDateOnly(reference);
if (!parsed) return notification.tipo;
@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy {
return parsed < today ? 'Vencido' : 'AVencer';
}
isVencido(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'Vencido';
}
isAVencer(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'AVencer';
}
isAutoRenew(notification: NotificationDto): boolean {
return this.getNotificationTipo(notification) === 'RenovacaoAutomatica';
}
getStatusLabel(notification: NotificationDto): string {
if (this.isAutoRenew(notification)) return 'Renovação automática';
return this.isVencido(notification) ? 'Vencido' : 'A vencer';
}
goToVigencia(notification: NotificationDto): void {
const id = (notification.vigenciaLineId ?? '').trim();
const linha = (notification.linha ?? '').trim();
if (!id && !linha) return;
this.router.navigate(['/vigencia'], {
queryParams: { lineId: id || null, linha: linha || null, open: 'edit' }
});
}
renewFromNotification(notification: NotificationDto): void {
if (!this.isAVencer(notification)) return;
const years = 2;
const lockKey = notification.id;
if (this.renewingKey === lockKey) return;
this.renewingKey = lockKey;
const vigenciaLineId = (notification.vigenciaLineId ?? '').trim();
if (vigenciaLineId) {
this.scheduleByVigenciaId(vigenciaLineId);
return;
}
const linha = (notification.linha ?? '').trim();
if (!linha) {
this.renewingKey = null;
return;
}
const onlyDigits = linha.replace(/\D/g, '');
const lookup = onlyDigits || linha;
this.vigenciaService.getVigencia({
search: lookup,
page: 1,
pageSize: 20,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res) => {
const rows = res?.items ?? [];
const found = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0];
const id = (found?.id ?? '').trim();
if (!id) {
this.renewingKey = null;
return;
}
this.scheduleByVigenciaId(id);
},
error: () => {
this.renewingKey = null;
}
});
}
isRenewing(notification: NotificationDto): boolean {
return this.renewingKey === notification.id;
}
private scheduleByVigenciaId(id: string): void {
const years = 2;
this.vigenciaService.configureAutoRenew(id, { years }).subscribe({
next: () => {
this.renewingKey = null;
this.loadNotifications();
},
error: () => {
this.renewingKey = null;
}
});
}
private loadNotifications() {
this.loading = true;
this.error = false;
@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy {
private shouldMarkRead(n: NotificationDto): boolean {
if (this.filter === 'todas') return true;
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer';
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido';
if (this.filter === 'aVencer') return this.isAVencer(n);
if (this.filter === 'vencidas') return this.isVencido(n);
return false;
}
@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy {
return this.notifications.filter(n => n.lida);
}
if (this.filter === 'vencidas') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'Vencido');
return this.notifications.filter(n => !n.lida && this.isVencido(n));
}
if (this.filter === 'aVencer') {
return this.notifications.filter(n => !n.lida && this.getNotificationTipo(n) === 'AVencer');
return this.notifications.filter(n => !n.lida && this.isAVencer(n));
}
// "todas" aqui representa o inbox: pendentes (não lidas).
return this.notifications.filter(n => !n.lida);

View File

@ -119,7 +119,7 @@
<th>LINHA</th>
<th>CONTA</th>
<th>USUÁRIO</th>
<th>PLANO</th>
<th class="plano-col">PLANO</th>
<th>EFETIVAÇÃO</th>
<th>VENCIMENTO</th>
<th class="text-end">TOTAL</th>
@ -132,7 +132,7 @@
<td class="fw-black text-blue">{{ row.linha }}</td>
<td class="text-dark small">{{ row.conta || '-' }}</td>
<td class="text-muted small">{{ row.usuario || '-' }}</td>
<td class="text-muted small td-clip" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
<td class="text-muted small td-clip plano-col" [title]="row.planoContrato">{{ row.planoContrato || '-' }}</td>
<td class="text-muted small fw-bold">
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
@ -146,8 +146,16 @@
{{ (row.total || 0) | currency:'BRL' }}
</td>
<td>
<td class="actions-col">
<div class="action-group justify-content-center">
<span class="renew-chip" *ngIf="row.autoRenewYears">{{ getRenewalBadge(row) }}</span>
<button
*ngIf="isAVencer(row.dtTerminoFidelizacao)"
class="btn btn-primary btn-xs"
(click)="scheduleAutoRenew(row)"
title="Renovar por mais 2 anos">
Renovar +2 anos
</button>
<button class="btn-icon primary" (click)="openDetails(row)" title="Ver Detalhes"><i class="bi bi-eye"></i></button>
<button *ngIf="isAdmin" class="btn-icon primary" (click)="openEdit(row)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isAdmin" class="btn-icon danger" (click)="openDelete(row)" title="Excluir"><i class="bi bi-trash"></i></button>
@ -236,6 +244,12 @@
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Renovação</span>
<span class="val">
{{ selectedRow?.autoRenewYears ? ('Auto +' + selectedRow?.autoRenewYears + ' ano(s)') : 'Não programada' }}
</span>
</div>
<div class="info-item">
<span class="lbl">Valor Total</span>
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>

View File

@ -286,9 +286,12 @@
.chip-muted { font-size: 0.75rem; font-weight: 800; color: rgba(17,18,20,0.55); padding: 4px 10px; border-radius: 999px; background: rgba(17,18,20,0.04); }
/* TABELA MUREG STYLE */
.inner-table-wrap { max-height: 500px; overflow-y: auto; }
.table-wrap { overflow: auto; }
.inner-table-wrap { max-height: 500px; overflow: auto; }
.table-modern {
width: 100%; border-collapse: separate; border-spacing: 0;
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;
@ -301,9 +304,26 @@
.fw-black { font-weight: 950; }
.text-brand { color: var(--brand) !important; }
.text-blue { color: var(--blue) !important; }
.td-clip { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.td-clip {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.actions-col { min-width: 152px; }
.plano-col {
max-width: 220px;
}
.actions-col {
min-width: 280px;
width: 280px;
text-align: center;
padding-left: 16px !important;
padding-right: 16px !important;
}
.action-group {
display: flex;
@ -312,6 +332,27 @@
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
width: 100%;
margin: 0 auto;
}
.renew-chip {
font-size: 0.66rem;
font-weight: 900;
color: #92400e;
background: rgba(245, 158, 11, 0.18);
border: 1px solid rgba(245, 158, 11, 0.38);
border-radius: 999px;
padding: 4px 8px;
}
.btn-xs {
min-height: 26px;
padding: 4px 7px;
font-size: 0.66rem;
font-weight: 800;
border-radius: 8px;
white-space: nowrap;
}
.btn-icon {
@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
}
.actions-col {
min-width: 120px;
min-width: 210px;
width: 210px;
}
.action-group {
@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
}
.actions-col {
min-width: 106px;
min-width: 190px;
width: 190px;
}
.action-group {

View File

@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { AuthService } from '../../services/auth.service';
@ -104,12 +106,14 @@ export class VigenciaComponent implements OnInit, OnDestroy {
toastType: ToastType = 'success';
private toastTimer: any = null;
private searchTimer: any = null;
private readonly subs = new Subscription();
constructor(
private vigenciaService: VigenciaService,
private authService: AuthService,
private linesService: LinesService,
private planAutoFill: PlanAutoFillService
private planAutoFill: PlanAutoFillService,
private route: ActivatedRoute
) {}
ngOnInit(): void {
@ -117,11 +121,13 @@ export class VigenciaComponent implements OnInit, OnDestroy {
this.loadClients();
this.loadPlanRules();
this.fetch(1);
this.bindOpenFromNotificationQuery();
}
ngOnDestroy(): void {
if (this.searchTimer) clearTimeout(this.searchTimer);
if (this.toastTimer) clearTimeout(this.toastTimer);
this.subs.unsubscribe();
}
setView(mode: ViewMode): void {
@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return this.startOfDay(d) >= this.startOfDay(new Date());
}
public isAVencer(dateValue: any): boolean {
if (!dateValue) return false;
const d = this.parseAnyDate(dateValue);
if (!d) return false;
const today = this.startOfDay(new Date());
const end = this.startOfDay(d);
const days = Math.round((end.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
return days >= 0 && days <= 30;
}
getRenewalBadge(row: VigenciaRow): string {
if (!row.autoRenewYears) return '';
return `Auto +${row.autoRenewYears} ano(s)`;
}
public parseAnyDate(value: any): Date | null {
if (!value) return null;
const d = new Date(value);
@ -273,6 +294,22 @@ export class VigenciaComponent implements OnInit, OnDestroy {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.fetch(1);
}
scheduleAutoRenew(row: VigenciaRow): void {
if (!row?.id) return;
const years = 2;
this.vigenciaService.configureAutoRenew(row.id, { years }).subscribe({
next: () => {
row.autoRenewYears = years;
row.autoRenewReferenceEndDate = row.dtTerminoFidelizacao;
row.autoRenewConfiguredAt = new Date().toISOString();
this.showToast(`Renovação automática (+${years} ano${years > 1 ? 's' : ''}) programada.`, 'success');
},
error: () => this.showToast('Não foi possível programar a renovação automática.', 'danger')
});
}
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
closeDetails() { this.detailsOpen = false; }
@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy {
return Number.isNaN(n) ? null : n;
}
private bindOpenFromNotificationQuery(): void {
this.subs.add(
this.route.queryParamMap.subscribe((params) => {
const lineId = (params.get('lineId') ?? '').trim();
const linha = (params.get('linha') ?? '').trim();
if (!lineId && !linha) return;
const openMode = (params.get('open') ?? 'edit').trim().toLowerCase();
if (lineId) {
this.openVigenciaLineById(lineId, openMode);
} else if (linha) {
this.openVigenciaLineByNumber(linha, openMode);
}
})
);
}
private openVigenciaLineById(lineId: string, openMode: string): void {
this.vigenciaService.getById(lineId).subscribe({
next: (row) => {
if (this.isAdmin && openMode !== 'details') {
this.openEdit(row);
return;
}
this.openDetails(row);
},
error: () => this.showToast('Não foi possível abrir a linha da vigência pela notificação.', 'danger')
});
}
private openVigenciaLineByNumber(linha: string, openMode: string): void {
const onlyDigits = (linha || '').replace(/\D/g, '');
const lookup = onlyDigits || linha;
if (!lookup) return;
this.vigenciaService.getVigencia({
search: lookup,
page: 1,
pageSize: 20,
sortBy: 'item',
sortDir: 'asc'
}).subscribe({
next: (res) => {
const rows = res?.items ?? [];
const match = rows.find(r => (r.linha ?? '').replace(/\D/g, '') === onlyDigits) ?? rows[0];
if (!match) {
this.showToast('Linha da notificação não encontrada na vigência.', 'danger');
return;
}
if (this.isAdmin && openMode !== 'details') {
this.openEdit(match);
return;
}
this.openDetails(match);
},
error: () => this.showToast('Não foi possível localizar a linha da notificação na vigência.', 'danger')
});
}
handleError(err: HttpErrorResponse, msg: string) {
this.loading = false;
this.expandedLoading = false;

View File

@ -42,7 +42,7 @@ export interface HistoricoQuery {
pageName?: string;
action?: AuditAction | string;
entity?: string;
userId?: string;
user?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
@ -64,7 +64,7 @@ export class HistoricoService {
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
if (params.action) httpParams = httpParams.set('action', params.action);
if (params.entity) httpParams = httpParams.set('entity', params.entity);
if (params.userId) httpParams = httpParams.set('userId', params.userId);
if (params.user) httpParams = httpParams.set('user', params.user);
if (params.search) httpParams = httpParams.set('search', params.search);
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);

View File

@ -4,7 +4,7 @@ import { Observable, Subject, tap } from 'rxjs';
import { environment } from '../../environments/environment';
export type NotificationTipo = 'AVencer' | 'Vencido';
export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
export type NotificationDto = {
id: string;

View File

@ -22,6 +22,10 @@ export interface VigenciaRow {
planoContrato: string | null;
dtEfetivacaoServico: string | null;
dtTerminoFidelizacao: string | null;
autoRenewYears?: number | null;
autoRenewReferenceEndDate?: string | null;
autoRenewConfiguredAt?: string | null;
lastAutoRenewedAt?: string | null;
total: number | null;
createdAt?: string | null;
updatedAt?: string | null;
@ -40,6 +44,9 @@ export interface UpdateVigenciaRequest {
}
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
export interface ConfigureVigenciaRenewalRequest {
years: 2;
}
export interface VigenciaClientGroup {
cliente: string;
@ -118,4 +125,8 @@ export class VigenciaService {
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`);
}
configureAutoRenew(id: string, payload: ConfigureVigenciaRenewalRequest): Observable<void> {
return this.http.post<void>(`${this.baseApi}/lines/vigencia/${id}/renew`, payload);
}
}