Feat: Adição Lote de Linhas
This commit is contained in:
parent
ec3abc056f
commit
096306e852
15
README.md
15
README.md
|
|
@ -57,3 +57,18 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
|
||||||
## Additional Resources
|
## 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.
|
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`
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
"jasmine-core": "~5.9.0",
|
"jasmine-core": "~5.9.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|
@ -3732,13 +3733,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.32",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/beasties": {
|
"node_modules/beasties": {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/bootstrap": "^5.2.10",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
"jasmine-core": "~5.9.0",
|
"jasmine-core": "~5.9.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,10 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
getVigenciaLabel(notification: NotificationDto): string {
|
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 {
|
getVigenciaDate(notification: NotificationDto): string {
|
||||||
|
|
@ -357,7 +360,11 @@ export class Header implements AfterViewInit, OnDestroy {
|
||||||
return parsed.toLocaleDateString('pt-BR');
|
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 reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||||
const parsed = this.parseDateOnly(reference);
|
const parsed = this.parseDateOnly(reference);
|
||||||
if (!parsed) return notification.tipo;
|
if (!parsed) return notification.tipo;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
BillingUpdateRequest
|
BillingUpdateRequest
|
||||||
} from '../../services/billing';
|
} from '../../services/billing';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { LinesService } from '../../services/lines.service';
|
||||||
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation';
|
||||||
|
|
||||||
interface BillingClientGroup {
|
interface BillingClientGroup {
|
||||||
|
|
@ -51,6 +52,7 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(PLATFORM_ID) private platformId: object,
|
@Inject(PLATFORM_ID) private platformId: object,
|
||||||
private billing: BillingService,
|
private billing: BillingService,
|
||||||
|
private linesService: LinesService,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private authService: AuthService
|
private authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -106,6 +108,8 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
|
||||||
private searchTimer: any = null;
|
private searchTimer: any = null;
|
||||||
|
private searchResolvedClients: string[] = [];
|
||||||
|
private searchResolveVersion = 0;
|
||||||
|
|
||||||
// cache do ALL
|
// cache do ALL
|
||||||
private allCache: BillingItem[] = [];
|
private allCache: BillingItem[] = [];
|
||||||
|
|
@ -351,22 +355,59 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
onSearch() {
|
onSearch() {
|
||||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||||
|
|
||||||
this.searchTimer = setTimeout(() => {
|
this.searchTimer = setTimeout(async () => {
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
this.expandedGroup = null;
|
this.expandedGroup = null;
|
||||||
this.groupRows = [];
|
this.groupRows = [];
|
||||||
|
await this.resolveSearchClientsByLineOrChip();
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSearch() {
|
clearSearch() {
|
||||||
this.searchTerm = '';
|
this.searchTerm = '';
|
||||||
|
this.searchResolvedClients = [];
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
this.expandedGroup = null;
|
this.expandedGroup = null;
|
||||||
this.groupRows = [];
|
this.groupRows = [];
|
||||||
this.refreshData();
|
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
|
// Data
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|
@ -513,8 +554,12 @@ export class Faturamento implements AfterViewInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
const term = this.normalizeText(this.searchTerm);
|
const term = this.normalizeText(this.searchTerm);
|
||||||
|
const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x)));
|
||||||
if (term) {
|
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
|
// KPIs
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -302,9 +303,37 @@
|
||||||
<div class="group-body" *ngIf="expandedGroup === group.cliente">
|
<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">
|
<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>
|
<small class="text-muted fw-bold">Gerenciar Grupo</small>
|
||||||
<button class="btn btn-sm btn-add-line-group" (click)="onAddLineToGroup(group.cliente)">
|
<div class="d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
|
<ng-container *ngIf="hasGroupLineSelectionTools">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ✅ wrapper com classe extra para permitir MAIS ALTURA em notebook/TV via SCSS -->
|
<!-- ✅ 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">
|
<table class="table table-modern table-modern-responsive align-middle text-center mb-0" *ngIf="!loadingLines">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>ITEM</th>
|
||||||
<th>LINHA</th>
|
<th>LINHA</th>
|
||||||
<th>USUÁRIO</th>
|
<th>USUÁRIO</th>
|
||||||
<th>STATUS</th>
|
<th>STATUS</th>
|
||||||
<th>VENCIMENTO</th>
|
<th>VENCIMENTO</th>
|
||||||
<th>AÇÕES</th>
|
<th class="actions-col-main">AÇÕES</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let r of groupLines" class="table-row-item">
|
<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="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 class="text-dark">{{ r.usuario || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
<span class="status-pill" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small fw-bold">{{ r.contrato }}</td>
|
<td class="text-muted small fw-bold">{{ r.contrato }}</td>
|
||||||
<td>
|
<td class="actions-col-main">
|
||||||
<div class="action-group justify-content-center">
|
<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" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||||
|
|
@ -425,7 +477,7 @@
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th class="text-center">AÇÕES</th>
|
<th class="text-center actions-col-main">AÇÕES</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
|
@ -451,7 +503,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center fw-bold text-muted small">{{ r.skil }}</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 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">
|
<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" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
|
||||||
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
|
||||||
|
|
@ -500,14 +552,14 @@
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="modal-backdrop-custom"
|
class="modal-backdrop-custom"
|
||||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
|
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||||
(click)="closeAllModals()">
|
(click)="closeAllModals()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overlay (captura clique fora) -->
|
<!-- Overlay (captura clique fora) -->
|
||||||
<div
|
<div
|
||||||
class="modal-custom"
|
class="modal-custom"
|
||||||
*ngIf="detailOpen || financeOpen || editOpen || createOpen"
|
*ngIf="detailOpen || financeOpen || editOpen || createOpen || reservaTransferOpen || moveToReservaOpen"
|
||||||
(click)="closeAllModals()"
|
(click)="closeAllModals()"
|
||||||
>
|
>
|
||||||
<!-- CREATE MODAL -->
|
<!-- CREATE MODAL -->
|
||||||
|
|
@ -907,233 +959,122 @@
|
||||||
e podem ser diferentes entre linhas.
|
e podem ser diferentes entre linhas.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-mass-input-box">
|
<div class="batch-mass-input-box mb-3">
|
||||||
<div class="batch-mass-input-head">
|
<div class="batch-mass-input-head">
|
||||||
<div>
|
<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">
|
<div class="batch-mass-sub">
|
||||||
Cole ou digite várias linhas em sequência. Formato padrão:
|
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.
|
||||||
<code>linha;chip;usuario;tipoDeChip;planoContrato;status;empresaConta;conta;dtEfetivacaoServico;dtTerminoFidelizacao</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-mass-controls">
|
<div class="d-flex gap-2 align-items-center flex-wrap justify-content-end">
|
||||||
<label class="small fw-bold text-muted mb-0">Separador</label>
|
<button type="button" class="btn btn-sm btn-glass" (click)="onDownloadBatchExcelTemplate()" [disabled]="createSaving || batchExcelTemplateDownloading || batchExcelPreviewLoading">
|
||||||
<select class="form-select form-select-sm" [(ngModel)]="batchMassSeparatorMode" (ngModelChange)="onBatchMassInputChange()">
|
<span *ngIf="!batchExcelTemplateDownloading"><i class="bi bi-download me-1"></i> Baixar Modelo (GERAL)</span>
|
||||||
<option value="AUTO">Automático</option>
|
<span *ngIf="batchExcelTemplateDownloading"><span class="spinner-border spinner-border-sm me-2"></span> Baixando...</span>
|
||||||
<option value="SEMICOLON">;</option>
|
</button>
|
||||||
<option value="TAB">TAB</option>
|
<button type="button" class="btn btn-sm btn-brand" (click)="onImportBatchExcel()" [disabled]="createSaving || batchExcelPreviewLoading">
|
||||||
<option value="PIPE">|</option>
|
<span *ngIf="!batchExcelPreviewLoading"><i class="bi bi-paperclip me-1"></i> Anexar Excel</span>
|
||||||
</select>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="batch-mass-guide" open>
|
<div class="batch-mass-preview" *ngIf="batchExcelPreview as excelPreview">
|
||||||
<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-pills">
|
<div class="batch-mass-preview-pills">
|
||||||
<span class="summary-pill total">Reconhecidas: {{ batchMassPreview?.recognizedRows || 0 }}</span>
|
<span class="summary-pill total">Aba: {{ excelPreview.sheetName || 'GERAL' }}</span>
|
||||||
<span class="summary-pill ok">Válidas: {{ batchMassPreview?.validRows || 0 }}</span>
|
<span class="summary-pill total">Linhas lidas: {{ excelPreview.totalRows || 0 }}</span>
|
||||||
<span class="summary-pill warn" *ngIf="(batchMassPreview?.invalidRows || 0) > 0">
|
<span class="summary-pill ok">Válidas: {{ excelPreview.validRows || 0 }}</span>
|
||||||
Inválidas: {{ batchMassPreview?.invalidRows || 0 }}
|
<span class="summary-pill warn" *ngIf="(excelPreview.invalidRows || 0) > 0">Inválidas: {{ excelPreview.invalidRows || 0 }}</span>
|
||||||
</span>
|
<span class="summary-pill dup" *ngIf="(excelPreview.duplicateRows || 0) > 0">Duplicadas: {{ excelPreview.duplicateRows || 0 }}</span>
|
||||||
<span class="summary-pill dup" *ngIf="(batchMassPreview?.duplicateRows || 0) > 0">
|
<span class="summary-pill" *ngIf="excelPreview.nextItemStart > 0">Próx. ITÉM (sistema): {{ excelPreview.nextItemStart }}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-mass-preview-errors" *ngIf="(batchMassPreview?.parseErrors?.length || 0) > 0">
|
<div class="batch-mass-preview-errors" *ngIf="(excelPreview.headerErrors?.length || 0) > 0">
|
||||||
<div class="fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Erros de parsing</div>
|
<strong>Erros de cabeçalho/estrutura</strong>
|
||||||
<ul>
|
<ul class="mb-0 mt-1">
|
||||||
<li *ngFor="let err of batchMassPreview?.parseErrors">{{ err }}</li>
|
<li *ngFor="let err of excelPreview.headerErrors">
|
||||||
|
<strong *ngIf="err.column">{{ err.column }}:</strong> {{ err.message }}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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">
|
<table class="batch-mass-preview-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Linha origem</th>
|
<th>Planilha</th>
|
||||||
|
<th>ITÉM (origem)</th>
|
||||||
|
<th>ITÉM (sistema)</th>
|
||||||
<th>Linha</th>
|
<th>Linha</th>
|
||||||
<th>Chip</th>
|
<th>Chip</th>
|
||||||
<th>Plano</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Conta</th>
|
<th>Conta</th>
|
||||||
|
<th>Status</th>
|
||||||
<th>Validação</th>
|
<th>Validação</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let row of batchMassPreviewRowsPreview">
|
<tr *ngFor="let row of batchExcelPreviewRowsPreview">
|
||||||
<td>#{{ row.line }}</td>
|
<td>#{{ row.sourceRowNumber }}</td>
|
||||||
<td>{{ row.data['linha'] || '-' }}</td>
|
<td>{{ row.sourceItem ?? '-' }}</td>
|
||||||
<td>{{ row.data['chip'] || '-' }}</td>
|
<td>{{ row.generatedItemPreview ?? '-' }}</td>
|
||||||
<td>{{ row.data['planoContrato'] || '-' }}</td>
|
<td>{{ row.data.linha || '-' }}</td>
|
||||||
<td>{{ row.data['status'] || '-' }}</td>
|
<td>{{ row.data.chip || '-' }}</td>
|
||||||
<td>{{ row.data['conta'] || '-' }}</td>
|
<td>{{ row.data.conta || '-' }}</td>
|
||||||
<td>
|
<td>{{ row.data.status || '-' }}</td>
|
||||||
<span class="batch-row-valid" *ngIf="row.errors.length === 0"><i class="bi bi-check-circle-fill"></i> OK</span>
|
<td class="validation-cell">
|
||||||
<div class="batch-row-errors-compact" *ngIf="row.errors.length > 0" [attr.title]="row.errors.join(' | ')">
|
<div class="batch-row-valid" *ngIf="(row.errors.length || 0) === 0">
|
||||||
<div class="batch-row-error-main">{{ row.errors[0] }}</div>
|
<i class="bi bi-check-circle-fill"></i> OK
|
||||||
<div class="batch-row-more" *ngIf="row.errors.length > 1">+{{ row.errors.length - 1 }} pendência(s)</div>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="batch-mass-preview-foot" *ngIf="(batchMassPreview?.recognizedRows || 0) > 5">
|
<div class="batch-mass-preview-foot" *ngIf="(excelPreview.rows.length || 0) > (batchExcelPreviewRowsPreview.length || 0)">
|
||||||
Mostrando 5 de {{ batchMassPreview?.recognizedRows }} linha(s) na prévia.
|
Mostrando {{ batchExcelPreviewRowsPreview.length }} de {{ excelPreview.rows.length || 0 }} linha(s) na prévia da planilha.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1160,8 +1101,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-lines-empty" *ngIf="createBatchCount === 0">
|
<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
|
Nenhuma linha no lote ainda. Use a <strong>importação por planilha</strong> acima para pré-visualizar e
|
||||||
sequência e carregá-las na grade.
|
carregar as linhas na grade.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="batch-editor-layout" *ngIf="createBatchCount > 0">
|
<div class="batch-editor-layout" *ngIf="createBatchCount > 0">
|
||||||
|
|
@ -1272,7 +1213,7 @@
|
||||||
|
|
||||||
<div class="batch-selected-hint">
|
<div class="batch-selected-hint">
|
||||||
<i class="bi bi-cursor-fill"></i>
|
<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
|
<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.
|
cadastro unitário. `Plano Contrato`, `Status`, `Conta` e datas obrigatórias são validados por linha.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1525,6 +1466,198 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- DETAIL MODAL -->
|
||||||
<div
|
<div
|
||||||
*ngIf="detailOpen"
|
*ngIf="detailOpen"
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,118 @@
|
||||||
inset 0 1px 0 rgba(255,255,255,0.16);
|
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); } }
|
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
/* Inner Table Destravada */
|
/* Inner Table Destravada */
|
||||||
|
|
@ -351,6 +463,22 @@
|
||||||
.action-group { display: flex; justify-content: center; gap: 8px; }
|
.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); } }
|
.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 */
|
/* 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; } }
|
.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); } }
|
.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-xl-custom { width: min(1100px, 95vw); max-height: 85vh; }
|
||||||
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
|
.modal-card.modal-create { width: min(1280px, 96vw); max-height: 92vh; }
|
||||||
.modal-card.modal-create.batch-mode { width: min(1560px, 99vw); }
|
.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) === */
|
/* === MODAL DE EDITAR E SEÇÕES (Accordion) === */
|
||||||
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
/* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ interface LineRow {
|
||||||
id: string;
|
id: string;
|
||||||
item: string;
|
item: string;
|
||||||
linha: string;
|
linha: string;
|
||||||
|
chip?: string;
|
||||||
cliente: string;
|
cliente: string;
|
||||||
usuario: string;
|
usuario: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
@ -63,6 +64,7 @@ interface ApiLineList {
|
||||||
id: string;
|
id: string;
|
||||||
item: number;
|
item: number;
|
||||||
linha: string | null;
|
linha: string | null;
|
||||||
|
chip?: string | null;
|
||||||
cliente: string | null;
|
cliente: string | null;
|
||||||
usuario: string | null;
|
usuario: string | null;
|
||||||
vencConta: 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({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -183,6 +247,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
|
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
|
||||||
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
|
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('batchExcelInput') batchExcelInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
@ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>;
|
@ViewChild('editModal', { static: false }) editModal!: ElementRef<HTMLElement>;
|
||||||
@ViewChild('createModal', { static: false }) createModal!: 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`;
|
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
|
||||||
return `${apiBase}/lines`;
|
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;
|
loading = false;
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
|
||||||
|
|
@ -257,9 +327,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
batchMassInputText = '';
|
batchMassInputText = '';
|
||||||
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
|
batchMassSeparatorMode: BatchMassSeparatorMode = 'AUTO';
|
||||||
batchMassPreview: BatchMassPreviewResult | null = null;
|
batchMassPreview: BatchMassPreviewResult | null = null;
|
||||||
|
batchExcelPreview: BatchExcelPreviewResultDto | null = null;
|
||||||
|
batchExcelPreviewLoading = false;
|
||||||
|
batchExcelTemplateDownloading = false;
|
||||||
|
batchExcelPreviewApplyMode: BatchMassApplyMode = 'ADD';
|
||||||
createBatchValidationByUid: Record<number, BatchLineValidation> = {};
|
createBatchValidationByUid: Record<number, BatchLineValidation> = {};
|
||||||
createBatchValidationSummary: BatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
|
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;
|
detailData: any = null;
|
||||||
financeData: any = null;
|
financeData: any = null;
|
||||||
editModel: 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 }));
|
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 {
|
get isGroupMode(): boolean {
|
||||||
return this.viewMode === 'GROUPS';
|
return this.viewMode === 'GROUPS';
|
||||||
}
|
}
|
||||||
|
|
@ -628,7 +773,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
|
// ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock
|
||||||
// ============================================================
|
// ============================================================
|
||||||
private anyModalOpen(): boolean {
|
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() {
|
private cleanupModalArtifacts() {
|
||||||
|
|
@ -654,6 +799,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.financeOpen = false;
|
this.financeOpen = false;
|
||||||
this.editOpen = false;
|
this.editOpen = false;
|
||||||
this.createOpen = false;
|
this.createOpen = false;
|
||||||
|
this.reservaTransferOpen = false;
|
||||||
|
this.moveToReservaOpen = false;
|
||||||
|
|
||||||
this.detailData = null;
|
this.detailData = null;
|
||||||
this.financeData = null;
|
this.financeData = null;
|
||||||
|
|
@ -665,6 +812,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.batchDetailOpen = false;
|
this.batchDetailOpen = false;
|
||||||
this.batchMassPreview = null;
|
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
|
// Limpa overlays/locks residuais
|
||||||
this.cleanupModalArtifacts();
|
this.cleanupModalArtifacts();
|
||||||
|
|
@ -1377,8 +1531,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
toggleGroup(clientName: string) {
|
toggleGroup(clientName: string) {
|
||||||
if (this.expandedGroup === clientName) {
|
if (this.expandedGroup === clientName) {
|
||||||
this.expandedGroup = null;
|
this.expandedGroup = null;
|
||||||
|
this.groupLines = [];
|
||||||
|
this.clearReservaSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.clearReservaSelection();
|
||||||
this.expandedGroup = clientName;
|
this.expandedGroup = clientName;
|
||||||
|
|
||||||
const term = (this.searchTerm ?? '').trim();
|
const term = (this.searchTerm ?? '').trim();
|
||||||
|
|
@ -1390,6 +1547,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
fetchGroupLines(clientName: string, search?: string) {
|
fetchGroupLines(clientName: string, search?: string) {
|
||||||
const requestVersion = ++this.linesRequestVersion;
|
const requestVersion = ++this.linesRequestVersion;
|
||||||
this.groupLines = [];
|
this.groupLines = [];
|
||||||
|
this.clearReservaSelection();
|
||||||
this.loadingLines = true;
|
this.loadingLines = true;
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
|
|
@ -1410,6 +1568,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
item: String(x.item ?? ''),
|
item: String(x.item ?? ''),
|
||||||
linha: x.linha ?? '',
|
linha: x.linha ?? '',
|
||||||
|
chip: x.chip ?? '',
|
||||||
cliente: x.cliente ?? '',
|
cliente: x.cliente ?? '',
|
||||||
usuario: x.usuario ?? '',
|
usuario: x.usuario ?? '',
|
||||||
status: x.status ?? '',
|
status: x.status ?? '',
|
||||||
|
|
@ -1876,6 +2035,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
this.batchMassInputText = '';
|
this.batchMassInputText = '';
|
||||||
this.batchMassSeparatorMode = 'AUTO';
|
this.batchMassSeparatorMode = 'AUTO';
|
||||||
this.batchMassPreview = null;
|
this.batchMassPreview = null;
|
||||||
|
this.batchExcelPreview = null;
|
||||||
|
this.batchExcelPreviewLoading = false;
|
||||||
this.createBatchValidationByUid = {};
|
this.createBatchValidationByUid = {};
|
||||||
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
|
this.createBatchValidationSummary = { total: 0, valid: 0, invalid: 0, duplicates: 0 };
|
||||||
this.createSaving = false;
|
this.createSaving = false;
|
||||||
|
|
@ -2365,12 +2526,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
private recomputeBatchValidation() {
|
private recomputeBatchValidation() {
|
||||||
const byUid: Record<number, BatchLineValidation> = {};
|
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) => {
|
this.createBatchLines.forEach((row) => {
|
||||||
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
|
const linhaDigits = (row?.linha ?? '').toString().replace(/\D/g, '');
|
||||||
if (!linhaDigits) return;
|
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;
|
let valid = 0;
|
||||||
|
|
@ -2381,12 +2549,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
|
||||||
const linhaRaw = (row?.linha ?? '').toString().trim();
|
const linhaRaw = (row?.linha ?? '').toString().trim();
|
||||||
const chipRaw = (row?.chip ?? '').toString().trim();
|
const chipRaw = (row?.chip ?? '').toString().trim();
|
||||||
const linhaDigits = linhaRaw.replace(/\D/g, '');
|
const linhaDigits = linhaRaw.replace(/\D/g, '');
|
||||||
|
const chipDigits = chipRaw.replace(/\D/g, '');
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!linhaRaw) errors.push('Linha obrigatória.');
|
if (!linhaRaw) errors.push('Linha obrigatória.');
|
||||||
else if (!linhaDigits) errors.push('Número de linha inválido.');
|
else if (!linhaDigits) errors.push('Número de linha inválido.');
|
||||||
|
|
||||||
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
|
if (!chipRaw) errors.push('Chip (ICCID) obrigatório.');
|
||||||
|
else if (!chipDigits) errors.push('Chip (ICCID) inválido.');
|
||||||
|
|
||||||
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
|
const contaEmpresa = (row?.['contaEmpresa'] ?? '').toString().trim();
|
||||||
const conta = (row?.['conta'] ?? '').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 (!dtEfet) errors.push('Dt. Efetivação Serviço obrigatória.');
|
||||||
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
|
if (!dtFidel) errors.push('Dt. Término Fidelização obrigatória.');
|
||||||
|
|
||||||
const isDuplicate = !!linhaDigits && (counts.get(linhaDigits) ?? 0) > 1;
|
const isLinhaDuplicate = !!linhaDigits && (linhaCounts.get(linhaDigits) ?? 0) > 1;
|
||||||
if (isDuplicate) {
|
const isChipDuplicate = !!chipDigits && (chipCounts.get(chipDigits) ?? 0) > 1;
|
||||||
|
if (isLinhaDuplicate) {
|
||||||
errors.push('Linha duplicada no lote.');
|
errors.push('Linha duplicada no lote.');
|
||||||
|
}
|
||||||
|
if (isChipDuplicate) {
|
||||||
|
errors.push('Chip (ICCID) duplicado no lote.');
|
||||||
|
}
|
||||||
|
if (isLinhaDuplicate || isChipDuplicate) {
|
||||||
duplicates++;
|
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() {
|
private async saveCreateBatch() {
|
||||||
const clientError = this.validateCreateClientFields();
|
const clientError = this.validateCreateClientFields();
|
||||||
if (clientError) {
|
if (clientError) {
|
||||||
|
|
|
||||||
|
|
@ -87,11 +87,11 @@
|
||||||
[disabled]="loading">
|
[disabled]="loading">
|
||||||
</app-select>
|
</app-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-field">
|
<div class="filter-field filter-user">
|
||||||
<label>Usuário (ID)</label>
|
<label>Usuário</label>
|
||||||
<input type="text" placeholder="GUID do usuário" [(ngModel)]="filterUserId" [disabled]="loading" />
|
<input type="text" placeholder="Nome ou e-mail do usuário" [(ngModel)]="filterUser" [disabled]="loading" />
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-field">
|
<div class="filter-field filter-search">
|
||||||
<label>Busca geral</label>
|
<label>Busca geral</label>
|
||||||
<div class="input-group input-group-sm search-group">
|
<div class="input-group input-group-sm search-group">
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
<td>
|
<td>
|
||||||
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
|
<span class="badge-action" [ngClass]="actionClass(log.action)">{{ formatAction(log.action) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="entity-col">
|
||||||
<div class="entity-cell">
|
<div class="entity-cell">
|
||||||
<div class="entity-label td-clip" [title]="displayEntity(log)">
|
<div class="entity-label td-clip" [title]="displayEntity(log)">
|
||||||
{{ 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>
|
<i class="bi" [class.bi-chevron-down]="expandedLogId !== log.id" [class.bi-chevron-up]="expandedLogId === log.id"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="entity-id" *ngIf="log.entityId">{{ log.entityId }}</small>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="details-row" *ngIf="expandedLogId === log.id">
|
<tr class="details-row" *ngIf="expandedLogId === log.id">
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@
|
||||||
.filter-field {
|
.filter-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -224,6 +225,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||||
|
|
@ -244,11 +248,25 @@
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-user {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search-group {
|
.search-group {
|
||||||
max-width: 270px;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid rgba(17, 18, 20, 0.15);
|
border: 1px solid rgba(17, 18, 20, 0.15);
|
||||||
|
|
@ -262,6 +280,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group-text {
|
.input-group-text {
|
||||||
|
flex: 0 0 auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(17, 18, 20, 0.5);
|
color: rgba(17, 18, 20, 0.5);
|
||||||
|
|
@ -272,10 +291,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
height: auto;
|
height: 40px;
|
||||||
padding: 10px 0;
|
padding: 0 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
@ -285,6 +307,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-clear {
|
.btn-clear {
|
||||||
|
flex: 0 0 auto;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(17, 18, 20, 0.45);
|
color: rgba(17, 18, 20, 0.45);
|
||||||
|
|
@ -399,7 +422,8 @@
|
||||||
|
|
||||||
.table-modern th:nth-child(5),
|
.table-modern th:nth-child(5),
|
||||||
.table-modern td:nth-child(5) {
|
.table-modern td:nth-child(5) {
|
||||||
text-align: left;
|
text-align: center;
|
||||||
|
min-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-modern th:nth-child(2),
|
.table-modern th:nth-child(2),
|
||||||
|
|
@ -447,13 +471,15 @@
|
||||||
.entity-cell {
|
.entity-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-label {
|
.entity-label {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-id {
|
.entity-id {
|
||||||
|
|
@ -677,10 +703,10 @@
|
||||||
.entity-cell {
|
.entity-cell {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
gap: 6px;
|
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; }
|
.expand-btn { align-self: center; flex-shrink: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -870,18 +896,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-cell {
|
.entity-cell {
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-label {
|
.entity-label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
.entity-id {
|
|
||||||
margin-top: 2px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-row td {
|
.details-row td {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export class Historico implements OnInit {
|
||||||
|
|
||||||
filterPageName = '';
|
filterPageName = '';
|
||||||
filterAction = '';
|
filterAction = '';
|
||||||
filterUserId = '';
|
filterUser = '';
|
||||||
filterSearch = '';
|
filterSearch = '';
|
||||||
dateFrom = '';
|
dateFrom = '';
|
||||||
dateTo = '';
|
dateTo = '';
|
||||||
|
|
@ -84,7 +84,7 @@ export class Historico implements OnInit {
|
||||||
clearFilters(): void {
|
clearFilters(): void {
|
||||||
this.filterPageName = '';
|
this.filterPageName = '';
|
||||||
this.filterAction = '';
|
this.filterAction = '';
|
||||||
this.filterUserId = '';
|
this.filterUser = '';
|
||||||
this.filterSearch = '';
|
this.filterSearch = '';
|
||||||
this.dateFrom = '';
|
this.dateFrom = '';
|
||||||
this.dateTo = '';
|
this.dateTo = '';
|
||||||
|
|
@ -221,7 +221,7 @@ export class Historico implements OnInit {
|
||||||
pageSize: this.pageSize,
|
pageSize: this.pageSize,
|
||||||
pageName: this.filterPageName || undefined,
|
pageName: this.filterPageName || undefined,
|
||||||
action: this.filterAction || undefined,
|
action: this.filterAction || undefined,
|
||||||
userId: this.filterUserId?.trim() || undefined,
|
user: this.filterUser?.trim() || undefined,
|
||||||
search: this.filterSearch?.trim() || undefined,
|
search: this.filterSearch?.trim() || undefined,
|
||||||
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
|
dateFrom: this.toIsoDate(this.dateFrom, false) || undefined,
|
||||||
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
|
dateTo: this.toIsoDate(this.dateTo, true) || undefined,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar por cliente, conta, linha, usuário, plano, datas..."
|
placeholder="Pesquisar..."
|
||||||
[(ngModel)]="search"
|
[(ngModel)]="search"
|
||||||
(ngModelChange)="clearSelection()"
|
(ngModelChange)="clearSelection()"
|
||||||
/>
|
/>
|
||||||
|
|
@ -115,8 +115,8 @@
|
||||||
class="list-item"
|
class="list-item"
|
||||||
*ngFor="let n of filteredNotifications"
|
*ngFor="let n of filteredNotifications"
|
||||||
[class.is-read]="n.lida"
|
[class.is-read]="n.lida"
|
||||||
[class.is-danger]="getNotificationTipo(n) === 'Vencido'"
|
[class.is-danger]="isVencido(n)"
|
||||||
[class.is-warning]="getNotificationTipo(n) === 'AVencer'"
|
[class.is-warning]="isAVencer(n)"
|
||||||
>
|
>
|
||||||
<div class="status-strip"></div>
|
<div class="status-strip"></div>
|
||||||
|
|
||||||
|
|
@ -126,7 +126,12 @@
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="item-icon">
|
<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>
|
||||||
|
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
|
|
@ -156,8 +161,8 @@
|
||||||
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
|
<span class="meta-value">{{ n.planoContrato || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="badge-tag" [class.danger]="getNotificationTipo(n) === 'Vencido'" [class.warn]="getNotificationTipo(n) === 'AVencer'">
|
<span class="badge-tag" [class.danger]="isVencido(n)" [class.warn]="isAVencer(n)" [class.info]="isAutoRenew(n)">
|
||||||
{{ getNotificationTipo(n) === 'Vencido' ? 'Vencido' : 'A vencer' }}
|
{{ getStatusLabel(n) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,6 +178,27 @@
|
||||||
<i class="bi" [class.bi-arrow-counterclockwise]="n.lida" [class.bi-check2]="!n.lida"></i>
|
<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>
|
<span class="d-none d-md-inline">{{ n.lida ? 'Restaurar' : 'Marcar lida' }}</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,7 @@ $border: #e5e7eb;
|
||||||
|
|
||||||
.bi-x-circle-fill { color: $danger; }
|
.bi-x-circle-fill { color: $danger; }
|
||||||
.bi-clock-fill { color: $warning; }
|
.bi-clock-fill { color: $warning; }
|
||||||
|
.bi-check2-circle { color: $primary; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-content { flex: 1; min-width: 0; }
|
.item-content { flex: 1; min-width: 0; }
|
||||||
|
|
@ -290,9 +291,9 @@ $border: #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
min-width: 170px;
|
min-width: 170px;
|
||||||
text-align: right;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-pill {
|
.date-pill {
|
||||||
|
|
@ -323,17 +324,22 @@ $border: #e5e7eb;
|
||||||
|
|
||||||
&.danger { background: rgba($danger, 0.1); color: $danger; }
|
&.danger { background: rgba($danger, 0.1); color: $danger; }
|
||||||
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
|
&.warn { background: rgba($warning, 0.1); color: color.adjust($warning, $lightness: -10%); }
|
||||||
|
&.info { background: rgba($primary, 0.12); color: $primary; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
margin-left: 12px; align-self: center;
|
margin-left: 12px;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
.btn-action {
|
||||||
background: white; border: 1px solid $border;
|
background: white; border: 1px solid $border;
|
||||||
padding: 8px 12px; border-radius: 8px;
|
padding: 6px 10px; border-radius: 7px;
|
||||||
cursor: pointer;
|
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;
|
display: flex; align-items: center; gap: 6px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
|
@ -344,6 +350,18 @@ $border: #e5e7eb;
|
||||||
@media(min-width: 768px) { opacity: 0.6; }
|
@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)
|
RESPONSIVIDADE MOBILE (Central de Notificações)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
@ -634,14 +652,17 @@ $border: #e5e7eb;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
margin: 2px 0 0 0;
|
margin: 2px 0 0 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
.btn-action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px 10px;
|
padding: 6px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 9px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -727,7 +748,7 @@ $border: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
.btn-action {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
padding: 7px 8px;
|
padding: 6px 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
import { NotificationsService, NotificationDto } from '../../services/notifications.service';
|
||||||
|
import { VigenciaService } from '../../services/vigencia.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notificacoes',
|
selector: 'app-notificacoes',
|
||||||
|
|
@ -22,9 +24,14 @@ export class Notificacoes implements OnInit, OnDestroy {
|
||||||
bulkUnreadLoading = false;
|
bulkUnreadLoading = false;
|
||||||
exportLoading = false;
|
exportLoading = false;
|
||||||
selectedIds = new Set<string>();
|
selectedIds = new Set<string>();
|
||||||
|
renewingKey: string | null = null;
|
||||||
private readonly subs = new Subscription();
|
private readonly subs = new Subscription();
|
||||||
|
|
||||||
constructor(private notificationsService: NotificationsService) {}
|
constructor(
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private router: Router,
|
||||||
|
private vigenciaService: VigenciaService
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadNotifications();
|
this.loadNotifications();
|
||||||
|
|
@ -124,7 +131,11 @@ export class Notificacoes implements OnInit, OnDestroy {
|
||||||
return parsed.toLocaleDateString('pt-BR');
|
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 reference = notification.dtTerminoFidelizacao ?? notification.referenciaData;
|
||||||
const parsed = this.parseDateOnly(reference);
|
const parsed = this.parseDateOnly(reference);
|
||||||
if (!parsed) return notification.tipo;
|
if (!parsed) return notification.tipo;
|
||||||
|
|
@ -133,6 +144,94 @@ export class Notificacoes implements OnInit, OnDestroy {
|
||||||
return parsed < today ? 'Vencido' : 'AVencer';
|
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() {
|
private loadNotifications() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = false;
|
this.error = false;
|
||||||
|
|
@ -279,8 +378,8 @@ export class Notificacoes implements OnInit, OnDestroy {
|
||||||
|
|
||||||
private shouldMarkRead(n: NotificationDto): boolean {
|
private shouldMarkRead(n: NotificationDto): boolean {
|
||||||
if (this.filter === 'todas') return true;
|
if (this.filter === 'todas') return true;
|
||||||
if (this.filter === 'aVencer') return this.getNotificationTipo(n) === 'AVencer';
|
if (this.filter === 'aVencer') return this.isAVencer(n);
|
||||||
if (this.filter === 'vencidas') return this.getNotificationTipo(n) === 'Vencido';
|
if (this.filter === 'vencidas') return this.isVencido(n);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,10 +388,10 @@ export class Notificacoes implements OnInit, OnDestroy {
|
||||||
return this.notifications.filter(n => n.lida);
|
return this.notifications.filter(n => n.lida);
|
||||||
}
|
}
|
||||||
if (this.filter === 'vencidas') {
|
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') {
|
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).
|
// "todas" aqui representa o inbox: pendentes (não lidas).
|
||||||
return this.notifications.filter(n => !n.lida);
|
return this.notifications.filter(n => !n.lida);
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
<th>LINHA</th>
|
<th>LINHA</th>
|
||||||
<th>CONTA</th>
|
<th>CONTA</th>
|
||||||
<th>USUÁRIO</th>
|
<th>USUÁRIO</th>
|
||||||
<th>PLANO</th>
|
<th class="plano-col">PLANO</th>
|
||||||
<th>EFETIVAÇÃO</th>
|
<th>EFETIVAÇÃO</th>
|
||||||
<th>VENCIMENTO</th>
|
<th>VENCIMENTO</th>
|
||||||
<th class="text-end">TOTAL</th>
|
<th class="text-end">TOTAL</th>
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
<td class="fw-black text-blue">{{ row.linha }}</td>
|
<td class="fw-black text-blue">{{ row.linha }}</td>
|
||||||
<td class="text-dark small">{{ row.conta || '-' }}</td>
|
<td class="text-dark small">{{ row.conta || '-' }}</td>
|
||||||
<td class="text-muted small">{{ row.usuario || '-' }}</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">
|
<td class="text-muted small fw-bold">
|
||||||
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
|
{{ row.dtEfetivacaoServico ? (row.dtEfetivacaoServico | date:'dd/MM/yyyy') : '-' }}
|
||||||
|
|
@ -146,8 +146,16 @@
|
||||||
{{ (row.total || 0) | currency:'BRL' }}
|
{{ (row.total || 0) | currency:'BRL' }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td class="actions-col">
|
||||||
<div class="action-group justify-content-center">
|
<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 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 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>
|
<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' }}
|
{{ isVencido(selectedRow?.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="info-item">
|
||||||
<span class="lbl">Valor Total</span>
|
<span class="lbl">Valor Total</span>
|
||||||
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
|
<span class="val text-brand">{{ (selectedRow?.total || 0) | currency:'BRL' }}</span>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
.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 */
|
/* 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 {
|
.table-modern {
|
||||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
thead th {
|
thead th {
|
||||||
position: sticky; top: 0; z-index: 10; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px);
|
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;
|
border-bottom: 2px solid rgba(227, 61, 207, 0.15); padding: 12px;
|
||||||
|
|
@ -301,9 +304,26 @@
|
||||||
.fw-black { font-weight: 950; }
|
.fw-black { font-weight: 950; }
|
||||||
.text-brand { color: var(--brand) !important; }
|
.text-brand { color: var(--brand) !important; }
|
||||||
.text-blue { color: var(--blue) !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 {
|
.action-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -312,6 +332,27 @@
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
white-space: 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 {
|
.btn-icon {
|
||||||
|
|
@ -980,7 +1021,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-col {
|
.actions-col {
|
||||||
min-width: 120px;
|
min-width: 210px;
|
||||||
|
width: 210px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-group {
|
.action-group {
|
||||||
|
|
@ -1111,7 +1153,8 @@ details[open] .transition-icon { transform: rotate(180deg); color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-col {
|
.actions-col {
|
||||||
min-width: 106px;
|
min-width: 190px;
|
||||||
|
width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-group {
|
.action-group {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
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 { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service';
|
||||||
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
|
@ -104,12 +106,14 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
toastType: ToastType = 'success';
|
toastType: ToastType = 'success';
|
||||||
private toastTimer: any = null;
|
private toastTimer: any = null;
|
||||||
private searchTimer: any = null;
|
private searchTimer: any = null;
|
||||||
|
private readonly subs = new Subscription();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private vigenciaService: VigenciaService,
|
private vigenciaService: VigenciaService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private linesService: LinesService,
|
private linesService: LinesService,
|
||||||
private planAutoFill: PlanAutoFillService
|
private planAutoFill: PlanAutoFillService,
|
||||||
|
private route: ActivatedRoute
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|
@ -117,11 +121,13 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
this.loadClients();
|
this.loadClients();
|
||||||
this.loadPlanRules();
|
this.loadPlanRules();
|
||||||
this.fetch(1);
|
this.fetch(1);
|
||||||
|
this.bindOpenFromNotificationQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||||
|
this.subs.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
setView(mode: ViewMode): void {
|
setView(mode: ViewMode): void {
|
||||||
|
|
@ -253,6 +259,21 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
return this.startOfDay(d) >= this.startOfDay(new Date());
|
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 {
|
public parseAnyDate(value: any): Date | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
|
|
@ -273,6 +294,22 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||||
this.fetch(1);
|
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; }
|
openDetails(r: VigenciaRow) { this.selectedRow = r; this.detailsOpen = true; }
|
||||||
closeDetails() { this.detailsOpen = false; }
|
closeDetails() { this.detailsOpen = false; }
|
||||||
|
|
||||||
|
|
@ -556,6 +593,66 @@ export class VigenciaComponent implements OnInit, OnDestroy {
|
||||||
return Number.isNaN(n) ? null : n;
|
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) {
|
handleError(err: HttpErrorResponse, msg: string) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.expandedLoading = false;
|
this.expandedLoading = false;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export interface HistoricoQuery {
|
||||||
pageName?: string;
|
pageName?: string;
|
||||||
action?: AuditAction | string;
|
action?: AuditAction | string;
|
||||||
entity?: string;
|
entity?: string;
|
||||||
userId?: string;
|
user?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
|
@ -64,7 +64,7 @@ export class HistoricoService {
|
||||||
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
|
if (params.pageName) httpParams = httpParams.set('pageName', params.pageName);
|
||||||
if (params.action) httpParams = httpParams.set('action', params.action);
|
if (params.action) httpParams = httpParams.set('action', params.action);
|
||||||
if (params.entity) httpParams = httpParams.set('entity', params.entity);
|
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.search) httpParams = httpParams.set('search', params.search);
|
||||||
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
|
if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom);
|
||||||
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
|
if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Observable, Subject, tap } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export type NotificationTipo = 'AVencer' | 'Vencido';
|
export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string;
|
||||||
|
|
||||||
export type NotificationDto = {
|
export type NotificationDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export interface VigenciaRow {
|
||||||
planoContrato: string | null;
|
planoContrato: string | null;
|
||||||
dtEfetivacaoServico: string | null;
|
dtEfetivacaoServico: string | null;
|
||||||
dtTerminoFidelizacao: string | null;
|
dtTerminoFidelizacao: string | null;
|
||||||
|
autoRenewYears?: number | null;
|
||||||
|
autoRenewReferenceEndDate?: string | null;
|
||||||
|
autoRenewConfiguredAt?: string | null;
|
||||||
|
lastAutoRenewedAt?: string | null;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
|
|
@ -40,6 +44,9 @@ export interface UpdateVigenciaRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
|
export interface CreateVigenciaRequest extends UpdateVigenciaRequest {}
|
||||||
|
export interface ConfigureVigenciaRenewalRequest {
|
||||||
|
years: 2;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VigenciaClientGroup {
|
export interface VigenciaClientGroup {
|
||||||
cliente: string;
|
cliente: string;
|
||||||
|
|
@ -118,4 +125,8 @@ export class VigenciaService {
|
||||||
remove(id: string): Observable<void> {
|
remove(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseApi}/lines/vigencia/${id}`);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue